이미지 & 구분선 구현

This commit is contained in:
dohyeons
2025-10-01 16:53:35 +09:00
parent f8be19c49f
commit d83264181c
12 changed files with 556 additions and 26 deletions

View File

@@ -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에서 이미 올바른 형태로 처리됨

View File

@@ -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();

View File

@@ -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)

View File

@@ -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% 완료)

View File

@@ -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) {
</div>
);
case "image":
return (
<div className="h-full w-full overflow-hidden">
<div className="mb-1 text-xs text-gray-500"></div>
{component.imageUrl ? (
<img
src={getFullImageUrl(component.imageUrl)}
alt="이미지"
style={{
width: "100%",
height: "calc(100% - 20px)",
objectFit: component.objectFit || "contain",
}}
/>
) : (
<div className="flex h-[calc(100%-20px)] w-full items-center justify-center border border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
</div>
)}
</div>
);
case "divider":
const lineWidth = component.lineWidth || 1;
const lineColor = component.lineColor || "#000000";
return (
<div className="flex h-full w-full items-center justify-center">
<div
style={{
width: component.orientation === "horizontal" ? "100%" : `${lineWidth}px`,
height: component.orientation === "vertical" ? "100%" : `${lineWidth}px`,
backgroundColor: lineColor,
...(component.lineStyle === "dashed" && {
backgroundImage: `repeating-linear-gradient(
${component.orientation === "horizontal" ? "90deg" : "0deg"},
${lineColor} 0px,
${lineColor} 10px,
transparent 10px,
transparent 20px
)`,
backgroundColor: "transparent",
}),
...(component.lineStyle === "dotted" && {
backgroundImage: `repeating-linear-gradient(
${component.orientation === "horizontal" ? "90deg" : "0deg"},
${lineColor} 0px,
${lineColor} 3px,
transparent 3px,
transparent 10px
)`,
backgroundColor: "transparent",
}),
...(component.lineStyle === "double" && {
boxShadow:
component.orientation === "horizontal"
? `0 ${lineWidth * 2}px 0 0 ${lineColor}`
: `${lineWidth * 2}px 0 0 0 ${lineColor}`,
}),
}}
/>
</div>
);
default:
return <div> </div>;
}

View File

@@ -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 className="h-4 w-4" /> },
{ type: "table", label: "테이블", icon: <Table className="h-4 w-4" /> },
{ type: "label", label: "레이블", icon: <Tag className="h-4 w-4" /> },
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
{ type: "divider", label: "구분선", icon: <Minus className="h-4 w-4" /> },
];
function DraggableComponentItem({ type, label, icon }: ComponentItem) {

View File

@@ -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);

View File

@@ -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<string>("properties");
const [uploadingImage, setUploadingImage] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
const selectedComponent = components.find((c) => c.id === selectedComponentId);
// 이미지 업로드 핸들러
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
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() {
</div>
</div>
{/* 이미지 속성 */}
{selectedComponent.type === "image" && (
<Card className="mt-4 border-purple-200 bg-purple-50">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-purple-900"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 파일 업로드 */}
<div>
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
className="hidden"
disabled={uploadingImage}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingImage}
className="flex-1"
>
{uploadingImage ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
{selectedComponent.imageUrl ? "파일 변경" : "파일 선택"}
</>
)}
</Button>
</div>
<p className="mt-1 text-xs text-gray-500">JPG, PNG, GIF, WEBP ( 10MB)</p>
{selectedComponent.imageUrl && (
<p className="mt-2 truncate text-xs text-purple-600">
: {selectedComponent.imageUrl}
</p>
)}
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.objectFit || "contain"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
objectFit: value as "contain" | "cover" | "fill" | "none",
})
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="contain"> ( )</SelectItem>
<SelectItem value="cover"> ()</SelectItem>
<SelectItem value="fill"></SelectItem>
<SelectItem value="none"> </SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
)}
{/* 구분선 속성 */}
{selectedComponent.type === "divider" && (
<Card className="mt-4 border-gray-200 bg-gray-50">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-gray-900"> </CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<Label className="text-xs"></Label>
<Select
value={selectedComponent.orientation || "horizontal"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
orientation: value as "horizontal" | "vertical",
})
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"></SelectItem>
<SelectItem value="vertical"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={selectedComponent.lineStyle || "solid"}
onValueChange={(value) =>
updateComponent(selectedComponent.id, {
lineStyle: value as "solid" | "dashed" | "dotted" | "double",
})
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid"></SelectItem>
<SelectItem value="dashed"></SelectItem>
<SelectItem value="dotted"></SelectItem>
<SelectItem value="double"></SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Input
type="number"
min="1"
max="20"
value={selectedComponent.lineWidth || 1}
onChange={(e) =>
updateComponent(selectedComponent.id, {
lineWidth: Number(e.target.value),
})
}
className="h-8"
/>
</div>
<div>
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Input
type="color"
value={selectedComponent.lineColor || "#000000"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
lineColor: e.target.value,
})
}
className="h-8 w-16"
/>
<Input
type="text"
value={selectedComponent.lineColor || "#000000"}
onChange={(e) =>
updateComponent(selectedComponent.id, {
lineColor: e.target.value,
})
}
className="h-8 flex-1 font-mono text-xs"
/>
</div>
</div>
</CardContent>
</Card>
)}
{/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */}
{(selectedComponent.type === "text" ||
selectedComponent.type === "label" ||

View File

@@ -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" ? (
<div className="text-xs text-gray-400"> </div>
) : null}
{component.type === "image" && component.imageUrl && (
<img
src={getFullImageUrl(component.imageUrl)}
alt="이미지"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
)}
{component.type === "divider" && (
<div
style={{
width: component.orientation === "horizontal" ? "100%" : `${component.lineWidth || 1}px`,
height: component.orientation === "vertical" ? "100%" : `${component.lineWidth || 1}px`,
backgroundColor: component.lineColor || "#000000",
...(component.lineStyle === "dashed" && {
backgroundImage: `repeating-linear-gradient(
${component.orientation === "horizontal" ? "90deg" : "0deg"},
${component.lineColor || "#000000"} 0px,
${component.lineColor || "#000000"} 10px,
transparent 10px,
transparent 20px
)`,
backgroundColor: "transparent",
}),
...(component.lineStyle === "dotted" && {
backgroundImage: `repeating-linear-gradient(
${component.orientation === "horizontal" ? "90deg" : "0deg"},
${component.lineColor || "#000000"} 0px,
${component.lineColor || "#000000"} 3px,
transparent 3px,
transparent 10px
)`,
backgroundColor: "transparent",
}),
...(component.lineStyle === "double" && {
boxShadow:
component.orientation === "horizontal"
? `0 ${(component.lineWidth || 1) * 2}px 0 0 ${component.lineColor || "#000000"}`
: `${(component.lineWidth || 1) * 2}px 0 0 0 ${component.lineColor || "#000000"}`,
}),
}}
/>
)}
</div>
);
})}

View File

@@ -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 => {

View File

@@ -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;
},
};

View File

@@ -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와 별도)
}
// 리포트 상세