이미지 & 구분선 구현
This commit is contained in:
parent
f8be19c49f
commit
d83264181c
|
|
@ -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 헤더 설정
|
||||
(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에서 이미 올바른 형태로 처리됨
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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% 완료)
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" ||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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와 별도)
|
||||
}
|
||||
|
||||
// 리포트 상세
|
||||
|
|
|
|||
Loading…
Reference in New Issue