From d83264181c6f0f1d6d62913daab5c63813a744bc Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 1 Oct 2025 16:53:35 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20&=20=EA=B5=AC?= =?UTF-8?q?=EB=B6=84=EC=84=A0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 33 ++- .../src/controllers/reportController.ts | 58 +++++ backend-node/src/routes/reportRoutes.ts | 29 +++ docs/리포트_관리_시스템_구현_진행상황.md | 34 ++- .../report/designer/CanvasComponent.tsx | 65 +++++ .../report/designer/ComponentPalette.tsx | 4 +- .../report/designer/ReportDesignerCanvas.tsx | 30 ++- .../designer/ReportDesignerRightPanel.tsx | 232 +++++++++++++++++- .../report/designer/ReportPreviewModal.tsx | 49 ++++ frontend/lib/api/client.ts | 16 ++ frontend/lib/api/reportApi.ts | 23 ++ frontend/types/report.ts | 9 + 12 files changed, 556 insertions(+), 26 deletions(-) diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 8d4313c0..807f01e4 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -72,21 +72,30 @@ app.use(compression()); app.use(express.json({ limit: "10mb" })); app.use(express.urlencoded({ extended: true, limit: "10mb" })); +// 정적 파일 서빙 전에 CORS 미들웨어 추가 (OPTIONS 요청 처리) +app.options("/uploads/*", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + res.sendStatus(200); +}); + // 정적 파일 서빙 (업로드된 파일들) app.use( "/uploads", - express.static(path.join(process.cwd(), "uploads"), { - setHeaders: (res, path) => { - // 파일 서빙 시 CORS 헤더 설정 - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); - res.setHeader( - "Access-Control-Allow-Headers", - "Content-Type, Authorization" - ); - res.setHeader("Cache-Control", "public, max-age=3600"); - }, - }) + (req, res, next) => { + // 모든 정적 파일 요청에 CORS 헤더 추가 + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, Authorization" + ); + res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); + res.setHeader("Cache-Control", "public, max-age=3600"); + next(); + }, + express.static(path.join(process.cwd(), "uploads")) ); // CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨 diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index af9ec4c3..f9162016 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -10,6 +10,8 @@ import { SaveLayoutRequest, CreateTemplateRequest, } from "../types/report"; +import path from "path"; +import fs from "fs"; export class ReportController { /** @@ -476,6 +478,62 @@ export class ReportController { return next(error); } } + + /** + * 이미지 파일 업로드 + * POST /api/admin/reports/upload-image + */ + async uploadImage(req: Request, res: Response, next: NextFunction) { + try { + if (!req.file) { + return res.status(400).json({ + success: false, + message: "이미지 파일이 필요합니다.", + }); + } + + const companyCode = req.body.companyCode || "SYSTEM"; + const file = req.file; + + // 파일 저장 경로 생성 + const uploadDir = path.join( + process.cwd(), + "uploads", + `company_${companyCode}`, + "reports" + ); + + // 디렉토리가 없으면 생성 + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + // 고유한 파일명 생성 (타임스탬프 + 원본 파일명) + const timestamp = Date.now(); + const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_"); + const fileName = `${timestamp}_${safeFileName}`; + const filePath = path.join(uploadDir, fileName); + + // 파일 저장 + fs.writeFileSync(filePath, file.buffer); + + // 웹에서 접근 가능한 URL 반환 + const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`; + + return res.json({ + success: true, + data: { + fileName, + fileUrl, + originalName: file.originalname, + size: file.size, + mimeType: file.mimetype, + }, + }); + } catch (error) { + return next(error); + } + } } export default new ReportController(); diff --git a/backend-node/src/routes/reportRoutes.ts b/backend-node/src/routes/reportRoutes.ts index aaa0449e..76e1a955 100644 --- a/backend-node/src/routes/reportRoutes.ts +++ b/backend-node/src/routes/reportRoutes.ts @@ -1,9 +1,33 @@ import { Router } from "express"; import reportController from "../controllers/reportController"; import { authenticateToken } from "../middleware/authMiddleware"; +import multer from "multer"; const router = Router(); +// Multer 설정 (메모리 저장) +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: 10 * 1024 * 1024, // 10MB 제한 + }, + fileFilter: (req, file, cb) => { + // 이미지 파일만 허용 + const allowedTypes = [ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", + ]; + if (allowedTypes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error("이미지 파일만 업로드 가능합니다. (jpg, png, gif, webp)")); + } + }, +}); + // 모든 리포트 API는 인증이 필요 router.use(authenticateToken); @@ -27,6 +51,11 @@ router.delete("/templates/:templateId", (req, res, next) => reportController.deleteTemplate(req, res, next) ); +// 이미지 업로드 (구체적인 경로를 먼저 배치) +router.post("/upload-image", upload.single("image"), (req, res, next) => + reportController.uploadImage(req, res, next) +); + // 리포트 목록 router.get("/", (req, res, next) => reportController.getReports(req, res, next) diff --git a/docs/리포트_관리_시스템_구현_진행상황.md b/docs/리포트_관리_시스템_구현_진행상황.md index df7c5d50..2563a6eb 100644 --- a/docs/리포트_관리_시스템_구현_진행상황.md +++ b/docs/리포트_관리_시스템_구현_진행상황.md @@ -201,20 +201,36 @@ ## 남은 작업 (우선순위순) 📋 -### Phase 1: 추가 컴포넌트 ⬅️ 다음 권장 작업 +### Phase 1: 추가 컴포넌트 ✅ 완료! -1. **이미지 컴포넌트** +1. **이미지 컴포넌트** ✅ - - 이미지 업로드 및 URL 입력 - - 크기 조절 및 정렬 + - [x] 파일 업로드 (multer, 10MB 제한) + - [x] 회사별 디렉토리 분리 저장 + - [x] 맞춤 방식 (contain/cover/fill/none) + - [x] CORS 설정으로 이미지 로딩 + - [x] 캔버스 및 미리보기 렌더링 - 로고, 서명, 도장 등에 활용 -2. **구분선 컴포넌트 (Divider)** +2. **구분선 컴포넌트 (Divider)** ✅ - - 가로/세로 구분선 - - 두께, 색상, 스타일(실선/점선) 설정 + - [x] 가로/세로 방향 선택 + - [x] 선 두께 (lineWidth) 독립 속성 + - [x] 선 색상 (lineColor) 독립 속성 + - [x] 선 스타일 (solid/dashed/dotted/double) + - [x] 캔버스 및 미리보기 렌더링 -3. **차트 컴포넌트** (선택사항) +**파일**: +- `backend-node/src/controllers/reportController.ts` (uploadImage) +- `backend-node/src/routes/reportRoutes.ts` (multer 설정) +- `frontend/types/report.ts` (이미지/구분선 속성) +- `frontend/components/report/designer/ComponentPalette.tsx` +- `frontend/components/report/designer/CanvasComponent.tsx` +- `frontend/components/report/designer/ReportDesignerRightPanel.tsx` +- `frontend/components/report/designer/ReportPreviewModal.tsx` +- `frontend/lib/api/client.ts` (getFullImageUrl) + +3. **차트 컴포넌트** (선택사항) ⬅️ 다음 권장 작업 - 막대 차트 - 선 차트 - 원형 차트 @@ -339,4 +355,4 @@ **최종 업데이트**: 2025-10-01 **작성자**: AI Assistant -**상태**: 레이아웃 도구 완료 (Phase 1 완료, 약 98% 완료) +**상태**: 이미지 & 구분선 컴포넌트 완료 (기본 컴포넌트 완료, 약 99% 완료) diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx index af2af362..1c01e31c 100644 --- a/frontend/components/report/designer/CanvasComponent.tsx +++ b/frontend/components/report/designer/CanvasComponent.tsx @@ -3,6 +3,7 @@ import { useRef, useState, useEffect } from "react"; import { ComponentConfig } from "@/types/report"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; +import { getFullImageUrl } from "@/lib/api/client"; interface CanvasComponentProps { component: ComponentConfig; @@ -318,6 +319,70 @@ export function CanvasComponent({ component }: CanvasComponentProps) { ); + case "image": + return ( +
+
이미지
+ {component.imageUrl ? ( + 이미지 + ) : ( +
+ 이미지를 업로드하세요 +
+ )} +
+ ); + + case "divider": + const lineWidth = component.lineWidth || 1; + const lineColor = component.lineColor || "#000000"; + + return ( +
+
+
+ ); + default: return
알 수 없는 컴포넌트
; } diff --git a/frontend/components/report/designer/ComponentPalette.tsx b/frontend/components/report/designer/ComponentPalette.tsx index ec9a6615..b0e251d2 100644 --- a/frontend/components/report/designer/ComponentPalette.tsx +++ b/frontend/components/report/designer/ComponentPalette.tsx @@ -1,7 +1,7 @@ "use client"; import { useDrag } from "react-dnd"; -import { Type, Table, Tag } from "lucide-react"; +import { Type, Table, Tag, Image, Minus } from "lucide-react"; interface ComponentItem { type: string; @@ -13,6 +13,8 @@ const COMPONENTS: ComponentItem[] = [ { type: "text", label: "텍스트", icon: }, { type: "table", label: "테이블", icon: }, { type: "label", label: "레이블", icon: }, + { type: "image", label: "이미지", icon: }, + { type: "divider", label: "구분선", icon: }, ]; function DraggableComponentItem({ type, label, icon }: ComponentItem) { diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index 84629f9e..e90a8a4b 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -44,14 +44,28 @@ export function ReportDesignerCanvas() { const x = offset.x - canvasRect.left; const y = offset.y - canvasRect.top; + // 컴포넌트 타입별 기본 설정 + let width = 200; + let height = 100; + + if (item.componentType === "table") { + height = 200; + } else if (item.componentType === "image") { + width = 150; + height = 150; + } else if (item.componentType === "divider") { + width = 300; + height = 2; + } + // 새 컴포넌트 생성 (Grid Snap 적용) const newComponent: ComponentConfig = { id: `comp_${uuidv4()}`, type: item.componentType, x: snapValueToGrid(Math.max(0, x - 100)), y: snapValueToGrid(Math.max(0, y - 25)), - width: snapValueToGrid(200), - height: snapValueToGrid(item.componentType === "table" ? 200 : 100), + width: snapValueToGrid(width), + height: snapValueToGrid(height), zIndex: components.length, fontSize: 13, fontFamily: "Malgun Gothic", @@ -65,6 +79,18 @@ export function ReportDesignerCanvas() { padding: 10, visible: true, printable: true, + // 이미지 전용 + ...(item.componentType === "image" && { + imageUrl: "", + objectFit: "contain" as const, + }), + // 구분선 전용 + ...(item.componentType === "divider" && { + orientation: "horizontal" as const, + lineStyle: "solid" as const, + lineWidth: 1, + lineColor: "#000000", + }), }; addComponent(newComponent); diff --git a/frontend/components/report/designer/ReportDesignerRightPanel.tsx b/frontend/components/report/designer/ReportDesignerRightPanel.tsx index 5989e2d5..c84d4ff9 100644 --- a/frontend/components/report/designer/ReportDesignerRightPanel.tsx +++ b/frontend/components/report/designer/ReportDesignerRightPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useRef } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -8,17 +8,78 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Trash2, Settings, Database, Link2 } from "lucide-react"; +import { Trash2, Settings, Database, Link2, Upload, Loader2 } from "lucide-react"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { QueryManager } from "./QueryManager"; +import { reportApi } from "@/lib/api/reportApi"; +import { useToast } from "@/hooks/use-toast"; export function ReportDesignerRightPanel() { const context = useReportDesigner(); const { selectedComponentId, components, updateComponent, removeComponent, queries } = context; const [activeTab, setActiveTab] = useState("properties"); + const [uploadingImage, setUploadingImage] = useState(false); + const fileInputRef = useRef(null); + const { toast } = useToast(); const selectedComponent = components.find((c) => c.id === selectedComponentId); + // 이미지 업로드 핸들러 + const handleImageUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !selectedComponent) return; + + // 파일 타입 체크 + if (!file.type.startsWith("image/")) { + toast({ + title: "오류", + description: "이미지 파일만 업로드 가능합니다.", + variant: "destructive", + }); + return; + } + + // 파일 크기 체크 (10MB) + if (file.size > 10 * 1024 * 1024) { + toast({ + title: "오류", + description: "파일 크기는 10MB 이하여야 합니다.", + variant: "destructive", + }); + return; + } + + try { + setUploadingImage(true); + + const result = await reportApi.uploadImage(file); + + if (result.success) { + // 업로드된 이미지 URL을 컴포넌트에 설정 + updateComponent(selectedComponent.id, { + imageUrl: result.data.fileUrl, + }); + + toast({ + title: "성공", + description: "이미지가 업로드되었습니다.", + }); + } + } catch (error) { + toast({ + title: "오류", + description: "이미지 업로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setUploadingImage(false); + // input 초기화 + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + } + }; + // 선택된 쿼리의 결과 필드 가져오기 const getQueryFields = (queryId: string): string[] => { const result = context.getQueryResult(queryId); @@ -300,6 +361,173 @@ export function ReportDesignerRightPanel() { + {/* 이미지 속성 */} + {selectedComponent.type === "image" && ( + + + 이미지 설정 + + + {/* 파일 업로드 */} +
+ +
+ + +
+

JPG, PNG, GIF, WEBP (최대 10MB)

+ {selectedComponent.imageUrl && ( +

+ 현재: {selectedComponent.imageUrl} +

+ )} +
+ +
+ + +
+
+
+ )} + + {/* 구분선 속성 */} + {selectedComponent.type === "divider" && ( + + + 구분선 설정 + + +
+ + +
+ +
+ + +
+ +
+ + + updateComponent(selectedComponent.id, { + lineWidth: Number(e.target.value), + }) + } + className="h-8" + /> +
+ +
+ +
+ + updateComponent(selectedComponent.id, { + lineColor: e.target.value, + }) + } + className="h-8 w-16" + /> + + updateComponent(selectedComponent.id, { + lineColor: e.target.value, + }) + } + className="h-8 flex-1 font-mono text-xs" + /> +
+
+
+
+ )} + {/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */} {(selectedComponent.type === "text" || selectedComponent.type === "label" || diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index 91e84570..bb30a6f3 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -14,6 +14,7 @@ import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { useState } from "react"; import { useToast } from "@/hooks/use-toast"; import { Document, Packer, Paragraph, TextRun, Table, TableCell, TableRow, WidthType } from "docx"; +import { getFullImageUrl } from "@/lib/api/client"; interface ReportPreviewModalProps { isOpen: boolean; @@ -321,6 +322,54 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) ) : component.type === "table" ? (
쿼리를 실행해주세요
) : null} + + {component.type === "image" && component.imageUrl && ( + 이미지 + )} + + {component.type === "divider" && ( +
+ )}
); })} diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 0722f9c9..99b7f8ce 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -29,6 +29,22 @@ const getApiBaseUrl = (): string => { export const API_BASE_URL = getApiBaseUrl(); +// 이미지 URL을 완전한 URL로 변환하는 함수 +export const getFullImageUrl = (imagePath: string): string => { + // 이미 전체 URL인 경우 그대로 반환 + if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) { + return imagePath; + } + + // /uploads로 시작하는 상대 경로인 경우 API 서버 주소 추가 + if (imagePath.startsWith("/uploads")) { + const baseUrl = API_BASE_URL.replace("/api", ""); // /api 제거 + return `${baseUrl}${imagePath}`; + } + + return imagePath; +}; + // JWT 토큰 관리 유틸리티 const TokenManager = { getToken: (): string | null => { diff --git a/frontend/lib/api/reportApi.ts b/frontend/lib/api/reportApi.ts index 85de1456..06a6250f 100644 --- a/frontend/lib/api/reportApi.ts +++ b/frontend/lib/api/reportApi.ts @@ -199,4 +199,27 @@ export const reportApi = { }>(`${BASE_URL}/templates/create-from-layout`, data); return response.data; }, + + // 이미지 업로드 + uploadImage: async (file: File) => { + const formData = new FormData(); + formData.append("image", file); + + const response = await apiClient.post<{ + success: boolean; + data: { + fileName: string; + fileUrl: string; + originalName: string; + size: number; + mimeType: string; + }; + message?: string; + }>(`${BASE_URL}/upload-image`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return response.data; + }, }; diff --git a/frontend/types/report.ts b/frontend/types/report.ts index 8c45d577..56a496b6 100644 --- a/frontend/types/report.ts +++ b/frontend/types/report.ts @@ -108,6 +108,15 @@ export interface ComponentConfig { conditional?: string; locked?: boolean; // 잠금 여부 (편집/이동/삭제 방지) groupId?: string; // 그룹 ID (같은 그룹 ID를 가진 컴포넌트는 함께 움직임) + // 이미지 전용 + imageUrl?: string; // 이미지 URL 또는 업로드된 파일 경로 + imageFile?: File; // 업로드된 이미지 파일 (클라이언트 측에서만 사용) + objectFit?: "contain" | "cover" | "fill" | "none"; // 이미지 맞춤 방식 + // 구분선 전용 + orientation?: "horizontal" | "vertical"; // 구분선 방향 + lineStyle?: "solid" | "dashed" | "dotted" | "double"; // 선 스타일 + lineWidth?: number; // 구분선 두께 (borderWidth와 별도) + lineColor?: string; // 구분선 색상 (borderColor와 별도) } // 리포트 상세