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"
+ />
+
+
+
+
+
+ )}
+
{/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */}
{(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와 별도)
}
// 리포트 상세