Merge pull request '리포트 관리 중간 병합' (#90) from feature/report into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/90
This commit is contained in:
hyeonsu 2025-10-13 15:19:01 +09:00
commit df64841c1e
44 changed files with 14516 additions and 394 deletions

View File

@ -30,6 +30,7 @@
"oracledb": "^6.9.0", "oracledb": "^6.9.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"redis": "^4.6.10", "redis": "^4.6.10",
"uuid": "^13.0.0",
"winston": "^3.11.0" "winston": "^3.11.0"
}, },
"devDependencies": { "devDependencies": {
@ -994,6 +995,15 @@
"node": ">=16" "node": ">=16"
} }
}, },
"node_modules/@azure/msal-node/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@ -10161,12 +10171,16 @@
} }
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "8.3.2", "version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist-node/bin/uuid"
} }
}, },
"node_modules/v8-compile-cache-lib": { "node_modules/v8-compile-cache-lib": {

View File

@ -44,6 +44,7 @@
"oracledb": "^6.9.0", "oracledb": "^6.9.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"redis": "^4.6.10", "redis": "^4.6.10",
"uuid": "^13.0.0",
"winston": "^3.11.0" "winston": "^3.11.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -48,6 +48,7 @@ import externalCallRoutes from "./routes/externalCallRoutes";
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes"; import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes"; import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
import dashboardRoutes from "./routes/dashboardRoutes"; import dashboardRoutes from "./routes/dashboardRoutes";
import reportRoutes from "./routes/reportRoutes";
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -75,21 +76,30 @@ app.use(compression());
app.use(express.json({ limit: "10mb" })); app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, 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( app.use(
"/uploads", "/uploads",
express.static(path.join(process.cwd(), "uploads"), { (req, res, next) => {
setHeaders: (res, path) => { // 모든 정적 파일 요청에 CORS 헤더 추가
// 파일 서빙 시 CORS 헤더 설정 res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); res.setHeader(
res.setHeader( "Access-Control-Allow-Headers",
"Access-Control-Allow-Headers", "Content-Type, Authorization"
"Content-Type, Authorization" );
); res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
res.setHeader("Cache-Control", "public, max-age=3600"); res.setHeader("Cache-Control", "public, max-age=3600");
}, next();
}) },
express.static(path.join(process.cwd(), "uploads"))
); );
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨 // CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
@ -181,6 +191,7 @@ app.use("/api/external-calls", externalCallRoutes);
app.use("/api/external-call-configs", externalCallConfigRoutes); app.use("/api/external-call-configs", externalCallConfigRoutes);
app.use("/api/dataflow", dataflowExecutionRoutes); app.use("/api/dataflow", dataflowExecutionRoutes);
app.use("/api/dashboards", dashboardRoutes); app.use("/api/dashboards", dashboardRoutes);
app.use("/api/admin/reports", reportRoutes);
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes); // app.use('/api/users', userRoutes);

View File

@ -0,0 +1,539 @@
/**
*
*/
import { Request, Response, NextFunction } from "express";
import reportService from "../services/reportService";
import {
CreateReportRequest,
UpdateReportRequest,
SaveLayoutRequest,
CreateTemplateRequest,
} from "../types/report";
import path from "path";
import fs from "fs";
export class ReportController {
/**
*
* GET /api/admin/reports
*/
async getReports(req: Request, res: Response, next: NextFunction) {
try {
const {
page = "1",
limit = "20",
searchText = "",
reportType = "",
useYn = "Y",
sortBy = "created_at",
sortOrder = "DESC",
} = req.query;
const result = await reportService.getReports({
page: parseInt(page as string, 10),
limit: parseInt(limit as string, 10),
searchText: searchText as string,
reportType: reportType as string,
useYn: useYn as string,
sortBy: sortBy as string,
sortOrder: sortOrder as "ASC" | "DESC",
});
return res.json({
success: true,
data: result,
});
} catch (error) {
return next(error);
}
}
/**
*
* GET /api/admin/reports/:reportId
*/
async getReportById(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const report = await reportService.getReportById(reportId);
if (!report) {
return res.status(404).json({
success: false,
message: "리포트를 찾을 수 없습니다.",
});
}
return res.json({
success: true,
data: report,
});
} catch (error) {
return next(error);
}
}
/**
*
* POST /api/admin/reports
*/
async createReport(req: Request, res: Response, next: NextFunction) {
try {
const data: CreateReportRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!data.reportNameKor || !data.reportType) {
return res.status(400).json({
success: false,
message: "리포트명과 리포트 타입은 필수입니다.",
});
}
const reportId = await reportService.createReport(data, userId);
return res.status(201).json({
success: true,
data: {
reportId,
},
message: "리포트가 생성되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
*
* PUT /api/admin/reports/:reportId
*/
async updateReport(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const data: UpdateReportRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
const success = await reportService.updateReport(reportId, data, userId);
if (!success) {
return res.status(400).json({
success: false,
message: "수정할 내용이 없습니다.",
});
}
return res.json({
success: true,
message: "리포트가 수정되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
*
* DELETE /api/admin/reports/:reportId
*/
async deleteReport(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const success = await reportService.deleteReport(reportId);
if (!success) {
return res.status(404).json({
success: false,
message: "리포트를 찾을 수 없습니다.",
});
}
return res.json({
success: true,
message: "리포트가 삭제되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
*
* POST /api/admin/reports/:reportId/copy
*/
async copyReport(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const userId = (req as any).user?.userId || "SYSTEM";
const newReportId = await reportService.copyReport(reportId, userId);
if (!newReportId) {
return res.status(404).json({
success: false,
message: "리포트를 찾을 수 없습니다.",
});
}
return res.status(201).json({
success: true,
data: {
reportId: newReportId,
},
message: "리포트가 복사되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
*
* GET /api/admin/reports/:reportId/layout
*/
async getLayout(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const layout = await reportService.getLayout(reportId);
if (!layout) {
return res.status(404).json({
success: false,
message: "레이아웃을 찾을 수 없습니다.",
});
}
// components JSON 파싱
const layoutData = {
...layout,
components: layout.components ? JSON.parse(layout.components) : [],
};
return res.json({
success: true,
data: layoutData,
});
} catch (error) {
return next(error);
}
}
/**
*
* PUT /api/admin/reports/:reportId/layout
*/
async saveLayout(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const data: SaveLayoutRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (
!data.canvasWidth ||
!data.canvasHeight ||
!data.pageOrientation ||
!data.components
) {
return res.status(400).json({
success: false,
message: "필수 레이아웃 정보가 누락되었습니다.",
});
}
await reportService.saveLayout(reportId, data, userId);
return res.json({
success: true,
message: "레이아웃이 저장되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
* 릿
* GET /api/admin/reports/templates
*/
async getTemplates(req: Request, res: Response, next: NextFunction) {
try {
const templates = await reportService.getTemplates();
return res.json({
success: true,
data: templates,
});
} catch (error) {
return next(error);
}
}
/**
* 릿
* POST /api/admin/reports/templates
*/
async createTemplate(req: Request, res: Response, next: NextFunction) {
try {
const data: CreateTemplateRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!data.templateNameKor || !data.templateType) {
return res.status(400).json({
success: false,
message: "템플릿명과 템플릿 타입은 필수입니다.",
});
}
const templateId = await reportService.createTemplate(data, userId);
return res.status(201).json({
success: true,
data: {
templateId,
},
message: "템플릿이 생성되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
* 릿
* POST /api/admin/reports/:reportId/save-as-template
*/
async saveAsTemplate(req: Request, res: Response, next: NextFunction) {
try {
const { reportId } = req.params;
const { templateNameKor, templateNameEng, description } = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!templateNameKor) {
return res.status(400).json({
success: false,
message: "템플릿명은 필수입니다.",
});
}
const templateId = await reportService.saveAsTemplate(
reportId,
templateNameKor,
templateNameEng,
description,
userId
);
return res.status(201).json({
success: true,
data: {
templateId,
},
message: "템플릿이 저장되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
* 릿 ( )
* POST /api/admin/reports/templates/create-from-layout
*/
async createTemplateFromLayout(
req: Request,
res: Response,
next: NextFunction
) {
try {
const {
templateNameKor,
templateNameEng,
templateType,
description,
layoutConfig,
defaultQueries = [],
} = req.body;
const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!templateNameKor) {
return res.status(400).json({
success: false,
message: "템플릿명은 필수입니다.",
});
}
if (!layoutConfig) {
return res.status(400).json({
success: false,
message: "레이아웃 설정은 필수입니다.",
});
}
const templateId = await reportService.createTemplateFromLayout(
templateNameKor,
templateNameEng,
templateType || "GENERAL",
description,
layoutConfig,
defaultQueries,
userId
);
return res.status(201).json({
success: true,
data: {
templateId,
},
message: "템플릿이 생성되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
* 릿
* DELETE /api/admin/reports/templates/:templateId
*/
async deleteTemplate(req: Request, res: Response, next: NextFunction) {
try {
const { templateId } = req.params;
const success = await reportService.deleteTemplate(templateId);
if (!success) {
return res.status(404).json({
success: false,
message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다.",
});
}
return res.json({
success: true,
message: "템플릿이 삭제되었습니다.",
});
} catch (error) {
return next(error);
}
}
/**
*
* POST /api/admin/reports/:reportId/queries/:queryId/execute
*/
async executeQuery(req: Request, res: Response, next: NextFunction) {
try {
const { reportId, queryId } = req.params;
const { parameters = {}, sqlQuery, externalConnectionId } = req.body;
const result = await reportService.executeQuery(
reportId,
queryId,
parameters,
sqlQuery,
externalConnectionId
);
return res.json({
success: true,
data: result,
});
} catch (error: any) {
return res.status(400).json({
success: false,
message: error.message || "쿼리 실행에 실패했습니다.",
});
}
}
/**
* DB ( )
* GET /api/admin/reports/external-connections
*/
async getExternalConnections(
req: Request,
res: Response,
next: NextFunction
) {
try {
const { ExternalDbConnectionService } = await import(
"../services/externalDbConnectionService"
);
const result = await ExternalDbConnectionService.getConnections({
is_active: "Y",
company_code: req.body.companyCode || "",
});
return res.json(result);
} catch (error) {
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

@ -0,0 +1,107 @@
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);
// 외부 DB 연결 목록 (구체적인 경로를 먼저 배치)
router.get("/external-connections", (req, res, next) =>
reportController.getExternalConnections(req, res, next)
);
// 템플릿 관련 라우트
router.get("/templates", (req, res, next) =>
reportController.getTemplates(req, res, next)
);
router.post("/templates", (req, res, next) =>
reportController.createTemplate(req, res, next)
);
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
router.post("/templates/create-from-layout", (req, res, next) =>
reportController.createTemplateFromLayout(req, res, next)
);
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)
);
// 리포트 생성
router.post("/", (req, res, next) =>
reportController.createReport(req, res, next)
);
// 리포트 복사 (구체적인 경로를 먼저 배치)
router.post("/:reportId/copy", (req, res, next) =>
reportController.copyReport(req, res, next)
);
// 템플릿으로 저장
router.post("/:reportId/save-as-template", (req, res, next) =>
reportController.saveAsTemplate(req, res, next)
);
// 레이아웃 관련 라우트
router.get("/:reportId/layout", (req, res, next) =>
reportController.getLayout(req, res, next)
);
router.put("/:reportId/layout", (req, res, next) =>
reportController.saveLayout(req, res, next)
);
// 쿼리 실행
router.post("/:reportId/queries/:queryId/execute", (req, res, next) =>
reportController.executeQuery(req, res, next)
);
// 리포트 상세
router.get("/:reportId", (req, res, next) =>
reportController.getReportById(req, res, next)
);
// 리포트 수정
router.put("/:reportId", (req, res, next) =>
reportController.updateReport(req, res, next)
);
// 리포트 삭제
router.delete("/:reportId", (req, res, next) =>
reportController.deleteReport(req, res, next)
);
export default router;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,152 @@
/**
*
*/
// 리포트 템플릿
export interface ReportTemplate {
template_id: string;
template_name_kor: string;
template_name_eng: string | null;
template_type: string;
is_system: string;
thumbnail_url: string | null;
description: string | null;
layout_config: string | null;
default_queries: string | null;
use_yn: string;
sort_order: number;
created_at: Date;
created_by: string | null;
updated_at: Date | null;
updated_by: string | null;
}
// 리포트 마스터
export interface ReportMaster {
report_id: string;
report_name_kor: string;
report_name_eng: string | null;
template_id: string | null;
report_type: string;
company_code: string | null;
description: string | null;
use_yn: string;
created_at: Date;
created_by: string | null;
updated_at: Date | null;
updated_by: string | null;
}
// 리포트 레이아웃
export interface ReportLayout {
layout_id: string;
report_id: string;
canvas_width: number;
canvas_height: number;
page_orientation: string;
margin_top: number;
margin_bottom: number;
margin_left: number;
margin_right: number;
components: string | null;
created_at: Date;
created_by: string | null;
updated_at: Date | null;
updated_by: string | null;
}
// 리포트 쿼리
export interface ReportQuery {
query_id: string;
report_id: string;
query_name: string;
query_type: "MASTER" | "DETAIL";
sql_query: string;
parameters: string[] | null;
external_connection_id: number | null; // 외부 DB 연결 ID (NULL이면 내부 DB)
display_order: number;
created_at: Date;
created_by: string | null;
updated_at: Date | null;
updated_by: string | null;
}
// 리포트 상세 (마스터 + 레이아웃 + 쿼리)
export interface ReportDetail {
report: ReportMaster;
layout: ReportLayout | null;
queries: ReportQuery[];
}
// 리포트 목록 조회 파라미터
export interface GetReportsParams {
page?: number;
limit?: number;
searchText?: string;
reportType?: string;
useYn?: string;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
// 리포트 목록 응답
export interface GetReportsResponse {
items: ReportMaster[];
total: number;
page: number;
limit: number;
}
// 리포트 생성 요청
export interface CreateReportRequest {
reportNameKor: string;
reportNameEng?: string;
templateId?: string;
reportType: string;
description?: string;
companyCode?: string;
}
// 리포트 수정 요청
export interface UpdateReportRequest {
reportNameKor?: string;
reportNameEng?: string;
reportType?: string;
description?: string;
useYn?: string;
}
// 레이아웃 저장 요청
export interface SaveLayoutRequest {
canvasWidth: number;
canvasHeight: number;
pageOrientation: string;
marginTop: number;
marginBottom: number;
marginLeft: number;
marginRight: number;
components: any[];
queries?: Array<{
id: string;
name: string;
type: "MASTER" | "DETAIL";
sqlQuery: string;
parameters: string[];
}>;
}
// 템플릿 목록 응답
export interface GetTemplatesResponse {
system: ReportTemplate[];
custom: ReportTemplate[];
}
// 템플릿 생성 요청
export interface CreateTemplateRequest {
templateNameKor: string;
templateNameEng?: string;
templateType: string;
description?: string;
layoutConfig?: any;
defaultQueries?: any;
}

View File

@ -0,0 +1,591 @@
# 리포트 디자이너 그리드 시스템 구현 계획
## 개요
현재 자유 배치 방식의 리포트 디자이너를 **그리드 기반 스냅 시스템**으로 전환합니다.
안드로이드 홈 화면의 위젯 배치 방식과 유사하게, 모든 컴포넌트는 그리드에 맞춰서만 배치 및 크기 조절이 가능합니다.
## 목표
1. **정렬된 레이아웃**: 그리드 기반으로 요소들이 자동 정렬
2. **Word/PDF 변환 개선**: 그리드 정보를 활용하여 정확한 문서 변환
3. **직관적인 UI**: 그리드 시각화를 통한 명확한 배치 가이드
4. **사용자 제어**: 그리드 크기, 가시성 등 사용자 설정 가능
## 핵심 개념
### 그리드 시스템
```typescript
interface GridConfig {
// 그리드 설정
cellWidth: number; // 그리드 셀 너비 (px)
cellHeight: number; // 그리드 셀 높이 (px)
rows: number; // 세로 그리드 수 (계산값: pageHeight / cellHeight)
columns: number; // 가로 그리드 수 (계산값: pageWidth / cellWidth)
// 표시 설정
visible: boolean; // 그리드 표시 여부
snapToGrid: boolean; // 그리드 스냅 활성화 여부
// 시각적 설정
gridColor: string; // 그리드 선 색상
gridOpacity: number; // 그리드 투명도 (0-1)
}
```
### 컴포넌트 위치/크기 (그리드 기반)
```typescript
interface ComponentPosition {
// 그리드 좌표 (셀 단위)
gridX: number; // 시작 열 (0부터 시작)
gridY: number; // 시작 행 (0부터 시작)
gridWidth: number; // 차지하는 열 수
gridHeight: number; // 차지하는 행 수
// 실제 픽셀 좌표 (계산값)
x: number; // gridX * cellWidth
y: number; // gridY * cellHeight
width: number; // gridWidth * cellWidth
height: number; // gridHeight * cellHeight
}
```
## 구현 단계
### Phase 1: 그리드 시스템 기반 구조
#### 1.1 타입 정의
- **파일**: `frontend/types/report.ts`
- **내용**:
- `GridConfig` 인터페이스 추가
- `ComponentConfig``gridX`, `gridY`, `gridWidth`, `gridHeight` 추가
- `ReportPage``gridConfig` 추가
#### 1.2 Context 확장
- **파일**: `frontend/contexts/ReportDesignerContext.tsx`
- **내용**:
- `gridConfig` 상태 추가
- `updateGridConfig()` 함수 추가
- `snapToGrid()` 유틸리티 함수 추가
- 컴포넌트 추가/이동/리사이즈 시 그리드 스냅 적용
#### 1.3 그리드 계산 유틸리티
- **파일**: `frontend/lib/utils/gridUtils.ts` (신규)
- **내용**:
```typescript
// 픽셀 좌표 → 그리드 좌표 변환
export function pixelToGrid(pixel: number, cellSize: number): number;
// 그리드 좌표 → 픽셀 좌표 변환
export function gridToPixel(grid: number, cellSize: number): number;
// 컴포넌트 위치/크기를 그리드에 스냅
export function snapComponentToGrid(
component: ComponentConfig,
gridConfig: GridConfig
): ComponentConfig;
// 그리드 충돌 감지
export function detectGridCollision(
component: ComponentConfig,
otherComponents: ComponentConfig[]
): boolean;
```
### Phase 2: 그리드 시각화
#### 2.1 그리드 레이어 컴포넌트
- **파일**: `frontend/components/report/designer/GridLayer.tsx` (신규)
- **내용**:
- Canvas 위에 그리드 선 렌더링
- SVG 또는 Canvas API 사용
- 그리드 크기/색상/투명도 적용
- 줌/스크롤 시에도 정확한 위치 유지
```tsx
interface GridLayerProps {
gridConfig: GridConfig;
pageWidth: number;
pageHeight: number;
}
export function GridLayer({
gridConfig,
pageWidth,
pageHeight,
}: GridLayerProps) {
if (!gridConfig.visible) return null;
// SVG로 그리드 선 렌더링
return (
<svg className="absolute inset-0 pointer-events-none">
{/* 세로 선 */}
{Array.from({ length: gridConfig.columns + 1 }).map((_, i) => (
<line
key={`v-${i}`}
x1={i * gridConfig.cellWidth}
y1={0}
x2={i * gridConfig.cellWidth}
y2={pageHeight}
stroke={gridConfig.gridColor}
strokeOpacity={gridConfig.opacity}
/>
))}
{/* 가로 선 */}
{Array.from({ length: gridConfig.rows + 1 }).map((_, i) => (
<line
key={`h-${i}`}
x1={0}
y1={i * gridConfig.cellHeight}
x2={pageWidth}
y2={i * gridConfig.cellHeight}
stroke={gridConfig.gridColor}
strokeOpacity={gridConfig.opacity}
/>
))}
</svg>
);
}
```
#### 2.2 Canvas 통합
- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx`
- **내용**:
- `<GridLayer />` 추가
- 컴포넌트 렌더링 시 그리드 기반 위치 사용
### Phase 3: 드래그 앤 드롭 스냅
#### 3.1 드래그 시 그리드 스냅
- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx`
- **내용**:
- `useDrop` 훅 수정
- 드롭 위치를 그리드에 스냅
- 실시간 스냅 가이드 표시
```typescript
const [, drop] = useDrop({
accept: ["TEXT", "LABEL", "TABLE", "SIGNATURE", "STAMP"],
drop: (item: any, monitor) => {
const offset = monitor.getClientOffset();
if (!offset) return;
// 캔버스 상대 좌표 계산
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
let x = offset.x - canvasRect.left;
let y = offset.y - canvasRect.top;
// 그리드 스냅 적용
if (gridConfig.snapToGrid) {
const gridX = Math.round(x / gridConfig.cellWidth);
const gridY = Math.round(y / gridConfig.cellHeight);
x = gridX * gridConfig.cellWidth;
y = gridY * gridConfig.cellHeight;
}
// 컴포넌트 추가
addComponent({ type: item.type, x, y });
},
});
```
#### 3.2 리사이즈 시 그리드 스냅
- **파일**: `frontend/components/report/designer/ComponentWrapper.tsx`
- **내용**:
- `react-resizable` 또는 `react-rnd``snap` 설정 활용
- 리사이즈 핸들 드래그 시 그리드 단위로만 크기 조절
```typescript
<Rnd
position={{ x: component.x, y: component.y }}
size={{ width: component.width, height: component.height }}
onDragStop={(e, d) => {
let newX = d.x;
let newY = d.y;
if (gridConfig.snapToGrid) {
const gridX = Math.round(newX / gridConfig.cellWidth);
const gridY = Math.round(newY / gridConfig.cellHeight);
newX = gridX * gridConfig.cellWidth;
newY = gridY * gridConfig.cellHeight;
}
updateComponent(component.id, { x: newX, y: newY });
}}
onResizeStop={(e, direction, ref, delta, position) => {
let newWidth = parseInt(ref.style.width);
let newHeight = parseInt(ref.style.height);
if (gridConfig.snapToGrid) {
const gridWidth = Math.round(newWidth / gridConfig.cellWidth);
const gridHeight = Math.round(newHeight / gridConfig.cellHeight);
newWidth = gridWidth * gridConfig.cellWidth;
newHeight = gridHeight * gridConfig.cellHeight;
}
updateComponent(component.id, {
width: newWidth,
height: newHeight,
...position,
});
}}
grid={
gridConfig.snapToGrid
? [gridConfig.cellWidth, gridConfig.cellHeight]
: undefined
}
/>
```
### Phase 4: 그리드 설정 UI
#### 4.1 그리드 설정 패널
- **파일**: `frontend/components/report/designer/GridSettingsPanel.tsx` (신규)
- **내용**:
- 그리드 크기 조절 (cellWidth, cellHeight)
- 그리드 표시/숨김 토글
- 스냅 활성화/비활성화 토글
- 그리드 색상/투명도 조절
```tsx
export function GridSettingsPanel() {
const { gridConfig, updateGridConfig } = useReportDesigner();
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">그리드 설정</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 그리드 표시 */}
<div className="flex items-center justify-between">
<Label>그리드 표시</Label>
<Switch
checked={gridConfig.visible}
onCheckedChange={(visible) => updateGridConfig({ visible })}
/>
</div>
{/* 스냅 활성화 */}
<div className="flex items-center justify-between">
<Label>그리드 스냅</Label>
<Switch
checked={gridConfig.snapToGrid}
onCheckedChange={(snapToGrid) => updateGridConfig({ snapToGrid })}
/>
</div>
{/* 셀 크기 */}
<div className="space-y-2">
<Label>셀 너비 (px)</Label>
<Input
type="number"
value={gridConfig.cellWidth}
onChange={(e) =>
updateGridConfig({ cellWidth: parseInt(e.target.value) })
}
min={10}
max={100}
/>
</div>
<div className="space-y-2">
<Label>셀 높이 (px)</Label>
<Input
type="number"
value={gridConfig.cellHeight}
onChange={(e) =>
updateGridConfig({ cellHeight: parseInt(e.target.value) })
}
min={10}
max={100}
/>
</div>
{/* 프리셋 */}
<div className="space-y-2">
<Label>프리셋</Label>
<Select
onValueChange={(value) => {
const presets: Record<
string,
{ cellWidth: number; cellHeight: number }
> = {
fine: { cellWidth: 10, cellHeight: 10 },
medium: { cellWidth: 20, cellHeight: 20 },
coarse: { cellWidth: 50, cellHeight: 50 },
};
updateGridConfig(presets[value]);
}}
>
<SelectTrigger>
<SelectValue placeholder="그리드 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fine">세밀 (10x10)</SelectItem>
<SelectItem value="medium">중간 (20x20)</SelectItem>
<SelectItem value="coarse">넓음 (50x50)</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
);
}
```
#### 4.2 툴바에 그리드 토글 추가
- **파일**: `frontend/components/report/designer/ReportDesignerToolbar.tsx`
- **내용**:
- 그리드 표시/숨김 버튼
- 그리드 설정 모달 열기 버튼
- 키보드 단축키 (`G` 키로 그리드 토글)
### Phase 5: Word 변환 개선
#### 5.1 그리드 기반 레이아웃 변환
- **파일**: `frontend/components/report/designer/ReportPreviewModal.tsx`
- **내용**:
- 그리드 정보를 활용하여 더 정확한 테이블 레이아웃 생성
- 그리드 행/열을 Word 테이블의 행/열로 매핑
```typescript
const handleDownloadWord = async () => {
// 그리드 기반으로 컴포넌트 배치 맵 생성
const gridMap: (ComponentConfig | null)[][] = Array(gridConfig.rows)
.fill(null)
.map(() => Array(gridConfig.columns).fill(null));
// 각 컴포넌트를 그리드 맵에 배치
for (const component of components) {
const gridX = Math.round(component.x / gridConfig.cellWidth);
const gridY = Math.round(component.y / gridConfig.cellHeight);
const gridWidth = Math.round(component.width / gridConfig.cellWidth);
const gridHeight = Math.round(component.height / gridConfig.cellHeight);
// 컴포넌트가 차지하는 모든 셀에 참조 저장
for (let y = gridY; y < gridY + gridHeight; y++) {
for (let x = gridX; x < gridX + gridWidth; x++) {
if (y < gridConfig.rows && x < gridConfig.columns) {
gridMap[y][x] = component;
}
}
}
}
// 그리드 맵을 Word 테이블로 변환
const tableRows: TableRow[] = [];
for (let y = 0; y < gridConfig.rows; y++) {
const cells: TableCell[] = [];
let x = 0;
while (x < gridConfig.columns) {
const component = gridMap[y][x];
if (!component) {
// 빈 셀
cells.push(new TableCell({ children: [new Paragraph("")] }));
x++;
} else {
// 컴포넌트 셀
const gridWidth = Math.round(component.width / gridConfig.cellWidth);
const gridHeight = Math.round(component.height / gridConfig.cellHeight);
const cell = createTableCell(component, gridWidth, gridHeight);
if (cell) cells.push(cell);
x += gridWidth;
}
}
if (cells.length > 0) {
tableRows.push(new TableRow({ children: cells }));
}
}
// ... Word 문서 생성
};
```
### Phase 6: 데이터 마이그레이션
#### 6.1 기존 레이아웃 자동 변환
- **파일**: `frontend/lib/utils/layoutMigration.ts` (신규)
- **내용**:
- 기존 절대 위치 데이터를 그리드 기반으로 변환
- 가장 가까운 그리드 셀에 스냅
- 마이그레이션 로그 생성
```typescript
export function migrateLayoutToGrid(
layout: ReportLayoutConfig,
gridConfig: GridConfig
): ReportLayoutConfig {
return {
...layout,
pages: layout.pages.map((page) => ({
...page,
gridConfig,
components: page.components.map((component) => {
// 픽셀 좌표를 그리드 좌표로 변환
const gridX = Math.round(component.x / gridConfig.cellWidth);
const gridY = Math.round(component.y / gridConfig.cellHeight);
const gridWidth = Math.max(
1,
Math.round(component.width / gridConfig.cellWidth)
);
const gridHeight = Math.max(
1,
Math.round(component.height / gridConfig.cellHeight)
);
return {
...component,
gridX,
gridY,
gridWidth,
gridHeight,
x: gridX * gridConfig.cellWidth,
y: gridY * gridConfig.cellHeight,
width: gridWidth * gridConfig.cellWidth,
height: gridHeight * gridConfig.cellHeight,
};
}),
})),
};
}
```
#### 6.2 마이그레이션 UI
- **파일**: `frontend/components/report/designer/MigrationModal.tsx` (신규)
- **내용**:
- 기존 리포트 로드 시 마이그레이션 필요 여부 체크
- 마이그레이션 전/후 미리보기
- 사용자 확인 후 적용
## 데이터베이스 스키마 변경
### report_layout_pages 테이블
```sql
ALTER TABLE report_layout_pages
ADD COLUMN grid_cell_width INTEGER DEFAULT 20,
ADD COLUMN grid_cell_height INTEGER DEFAULT 20,
ADD COLUMN grid_visible BOOLEAN DEFAULT true,
ADD COLUMN grid_snap_enabled BOOLEAN DEFAULT true,
ADD COLUMN grid_color VARCHAR(7) DEFAULT '#e5e7eb',
ADD COLUMN grid_opacity DECIMAL(3,2) DEFAULT 0.5;
```
### report_layout_components 테이블
```sql
ALTER TABLE report_layout_components
ADD COLUMN grid_x INTEGER,
ADD COLUMN grid_y INTEGER,
ADD COLUMN grid_width INTEGER,
ADD COLUMN grid_height INTEGER;
-- 기존 데이터 마이그레이션
UPDATE report_layout_components
SET
grid_x = ROUND(position_x / 20.0),
grid_y = ROUND(position_y / 20.0),
grid_width = GREATEST(1, ROUND(width / 20.0)),
grid_height = GREATEST(1, ROUND(height / 20.0))
WHERE grid_x IS NULL;
```
## 테스트 계획
### 단위 테스트
- `gridUtils.ts`의 모든 함수 테스트
- 그리드 좌표 ↔ 픽셀 좌표 변환 정확성
- 충돌 감지 로직
### 통합 테스트
- 드래그 앤 드롭 시 그리드 스냅 동작
- 리사이즈 시 그리드 스냅 동작
- 그리드 크기 변경 시 컴포넌트 재배치
### E2E 테스트
- 새 리포트 생성 및 그리드 설정
- 기존 리포트 마이그레이션
- Word 다운로드 시 레이아웃 정확성
## 예상 개발 일정
- **Phase 1**: 그리드 시스템 기반 구조 (2일)
- **Phase 2**: 그리드 시각화 (1일)
- **Phase 3**: 드래그 앤 드롭 스냅 (2일)
- **Phase 4**: 그리드 설정 UI (1일)
- **Phase 5**: Word 변환 개선 (2일)
- **Phase 6**: 데이터 마이그레이션 (1일)
- **테스트 및 디버깅**: (2일)
**총 예상 기간**: 11일
## 기술적 고려사항
### 성능 최적화
- 그리드 렌더링: SVG 대신 Canvas API 고려 (많은 셀의 경우)
- 메모이제이션: 그리드 계산 결과 캐싱
- 가상화: 큰 페이지에서 보이는 영역만 렌더링
### 사용자 경험
- 실시간 스냅 가이드: 드래그 중 스냅될 위치 미리 표시
- 키보드 단축키: 방향키로 그리드 단위 이동, Shift+방향키로 픽셀 단위 미세 조정
- 언두/리두: 그리드 스냅 적용 전/후 상태 저장
### 하위 호환성
- 기존 리포트는 자동 마이그레이션 제공
- 마이그레이션 옵션: 자동 / 수동 선택 가능
- 레거시 모드: 그리드 없이 자유 배치 가능 (옵션)
## 추가 기능 (향후 확장)
### 스마트 가이드
- 다른 컴포넌트와 정렬 시 가이드 라인 표시
- 균등 간격 가이드
### 그리드 템플릿
- 자주 사용하는 그리드 레이아웃 템플릿 제공
- 문서 종류별 프리셋 (계약서, 보고서, 송장 등)
### 그리드 병합
- 여러 그리드 셀을 하나로 병합
- 복잡한 레이아웃 지원
## 참고 자료
- Android Home Screen Widget System
- Microsoft Word Table Layout
- CSS Grid Layout
- Figma Auto Layout

View File

@ -0,0 +1,358 @@
# 리포트 관리 시스템 구현 진행 상황
## 프로젝트 개요
동적 리포트 디자이너 시스템 구현
- 사용자가 드래그 앤 드롭으로 리포트 레이아웃 설계
- SQL 쿼리 연동으로 실시간 데이터 표시
- 미리보기 및 인쇄 기능
---
## 완료된 작업 ✅
### 1. 데이터베이스 설계 및 구축
- [x] `report_template` 테이블 생성 (18개 초기 템플릿)
- [x] `report_master` 테이블 생성 (리포트 메타 정보)
- [x] `report_layout` 테이블 생성 (레이아웃 JSON)
- [x] `report_query` 테이블 생성 (쿼리 정의)
**파일**: `db/report_schema.sql`, `db/report_query_schema.sql`
### 2. 백엔드 API 구현
- [x] 리포트 CRUD API (생성, 조회, 수정, 삭제)
- [x] 템플릿 조회 API
- [x] 레이아웃 저장/조회 API
- [x] 쿼리 실행 API (파라미터 지원)
- [x] 리포트 복사 API
- [x] Raw SQL 기반 구현 (Prisma 대신 pg 사용)
**파일**:
- `backend-node/src/types/report.ts`
- `backend-node/src/services/reportService.ts`
- `backend-node/src/controllers/reportController.ts`
- `backend-node/src/routes/reportRoutes.ts`
### 3. 프론트엔드 - 리포트 목록 페이지
- [x] 리포트 리스트 조회 및 표시
- [x] 검색 기능
- [x] 페이지네이션
- [x] 새 리포트 생성 (디자이너로 이동)
- [x] 수정/복사/삭제 액션 버튼
**파일**:
- `frontend/app/(main)/admin/report/page.tsx`
- `frontend/components/report/ReportListTable.tsx`
- `frontend/hooks/useReportList.ts`
### 4. 프론트엔드 - 리포트 디자이너 기본 구조
- [x] Context 기반 상태 관리 (`ReportDesignerContext`)
- [x] 툴바 (저장, 미리보기, 초기화, 뒤로가기)
- [x] 3단 레이아웃 (좌측 팔레트 / 중앙 캔버스 / 우측 속성)
- [x] "new" 리포트 처리 (저장 시 생성)
**파일**:
- `frontend/contexts/ReportDesignerContext.tsx`
- `frontend/app/(main)/admin/report/designer/[reportId]/page.tsx`
- `frontend/components/report/designer/ReportDesignerToolbar.tsx`
### 5. 컴포넌트 팔레트 및 캔버스
- [x] 드래그 가능한 컴포넌트 목록 (텍스트, 레이블, 테이블)
- [x] 드래그 앤 드롭으로 캔버스에 컴포넌트 배치
- [x] 컴포넌트 이동 (드래그)
- [x] 컴포넌트 크기 조절 (리사이즈 핸들)
- [x] 컴포넌트 선택 및 삭제
**파일**:
- `frontend/components/report/designer/ComponentPalette.tsx`
- `frontend/components/report/designer/ReportDesignerCanvas.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
### 6. 쿼리 관리 시스템
- [x] 쿼리 추가/수정/삭제 (마스터/디테일)
- [x] SQL 파라미터 자동 감지 ($1, $2 등)
- [x] 파라미터 타입 선택 (text, number, date)
- [x] 파라미터 입력값 검증
- [x] 쿼리 실행 및 결과 표시
- [x] "new" 리포트에서도 쿼리 실행 가능
- [x] 실행 결과를 Context에 저장
**파일**:
- `frontend/components/report/designer/QueryManager.tsx`
- `frontend/contexts/ReportDesignerContext.tsx` (QueryResult 관리)
### 7. 데이터 바인딩 시스템
- [x] 속성 패널에서 컴포넌트-쿼리 연결
- [x] 텍스트/레이블: 쿼리 + 필드 선택
- [x] 테이블: 쿼리 선택 (모든 필드 자동 표시)
- [x] 캔버스에서 실제 데이터 표시 (바인딩된 필드의 값)
- [x] 실행 결과가 없으면 `{필드명}` 표시
**파일**:
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
### 8. 미리보기 및 내보내기
- [x] 미리보기 모달
- [x] 실제 쿼리 데이터로 렌더링
- [x] 편집용 UI 제거 (순수 데이터만 표시)
- [x] 브라우저 인쇄 기능
- [x] PDF 다운로드 (브라우저 네이티브 인쇄 기능)
- [x] WORD 다운로드 (docx 라이브러리)
- [x] 파일명 자동 생성 (리포트명\_날짜)
**파일**:
- `frontend/components/report/designer/ReportPreviewModal.tsx`
**사용 라이브러리**:
- `docx`: WORD 문서 생성 (PDF는 브라우저 기본 기능 사용)
### 9. 템플릿 시스템
- [x] 시스템 템플릿 적용 (발주서, 청구서, 기본)
- [x] 템플릿별 기본 컴포넌트 자동 배치
- [x] 템플릿별 기본 쿼리 자동 생성
- [x] 사용자 정의 템플릿 저장 기능
- [x] 사용자 정의 템플릿 목록 조회
- [x] 사용자 정의 템플릿 삭제
- [x] 사용자 정의 템플릿 적용 (백엔드 연동)
**파일**:
- `frontend/contexts/ReportDesignerContext.tsx` (템플릿 적용 로직)
- `frontend/components/report/designer/TemplatePalette.tsx`
- `frontend/components/report/designer/SaveAsTemplateModal.tsx`
- `backend-node/src/services/reportService.ts` (createTemplateFromLayout)
### 10. 외부 DB 연동
- [x] 쿼리별 외부 DB 연결 선택
- [x] 외부 DB 연결 목록 조회 API
- [x] 쿼리 실행 시 외부 DB 지원
- [x] 내부/외부 DB 선택 UI
**파일**:
- `frontend/components/report/designer/QueryManager.tsx`
- `backend-node/src/services/reportService.ts` (executeQuery with external DB)
### 11. 컴포넌트 스타일링
- [x] 폰트 크기 설정
- [x] 폰트 색상 설정 (컬러피커)
- [x] 폰트 굵기 (보통/굵게)
- [x] 텍스트 정렬 (좌/중/우)
- [x] 배경색 설정 (투명 옵션 포함)
- [x] 테두리 설정 (두께, 색상)
- [x] 캔버스 및 미리보기에 스타일 반영
**파일**:
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
### 12. 레이아웃 도구 (완료!)
- [x] **Grid Snap**: 10px 단위 그리드에 자동 정렬
- [x] **정렬 가이드라인**: 드래그 시 빨간색 가이드라인 표시
- [x] **복사/붙여넣기**: Ctrl+C/V로 컴포넌트 복사 (20px 오프셋)
- [x] **Undo/Redo**: 히스토리 관리 (Ctrl+Z / Ctrl+Shift+Z)
- [x] **컴포넌트 정렬**: 좌/우/상/하/가로중앙/세로중앙 정렬
- [x] **컴포넌트 배치**: 가로/세로 균등 배치 (3개 이상)
- [x] **크기 조정**: 같은 너비/높이/크기로 조정 (2개 이상)
- [x] **화살표 키 이동**: 1px 이동, Shift+화살표 10px 이동
- [x] **레이어 관리**: 맨 앞/뒤, 한 단계 앞/뒤 (Z-Index 조정)
- [x] **컴포넌트 잠금**: 편집/이동/삭제 방지, 🔒 표시
- [x] **눈금자 표시**: 가로/세로 mm 단위 눈금자
- [x] **컴포넌트 그룹화**: 여러 컴포넌트를 그룹으로 묶어 함께 이동, 👥 표시
**파일**:
- `frontend/contexts/ReportDesignerContext.tsx` (레이아웃 도구 로직)
- `frontend/components/report/designer/ReportDesignerToolbar.tsx` (버튼 UI)
- `frontend/components/report/designer/ReportDesignerCanvas.tsx` (Grid, 가이드라인)
- `frontend/components/report/designer/CanvasComponent.tsx` (잠금, 그룹)
- `frontend/components/report/designer/Ruler.tsx` (눈금자 컴포넌트)
---
## 진행 중인 작업 🚧
없음 (모든 레이아웃 도구 구현 완료!)
---
## 남은 작업 (우선순위순) 📋
### Phase 1: 추가 컴포넌트 ✅ 완료!
1. **이미지 컴포넌트**
- [x] 파일 업로드 (multer, 10MB 제한)
- [x] 회사별 디렉토리 분리 저장
- [x] 맞춤 방식 (contain/cover/fill/none)
- [x] CORS 설정으로 이미지 로딩
- [x] 캔버스 및 미리보기 렌더링
- 로고, 서명, 도장 등에 활용
2. **구분선 컴포넌트 (Divider)**
- [x] 가로/세로 방향 선택
- [x] 선 두께 (lineWidth) 독립 속성
- [x] 선 색상 (lineColor) 독립 속성
- [x] 선 스타일 (solid/dashed/dotted/double)
- [x] 캔버스 및 미리보기 렌더링
**파일**:
- `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. **차트 컴포넌트** (선택사항) ⬅️ 다음 권장 작업
- 막대 차트
- 선 차트
- 원형 차트
- 쿼리 데이터 연동
### Phase 2: 고급 기능
4. **조건부 서식**
- 특정 조건에 따른 스타일 변경
- 값 범위에 따른 색상 표시
- 수식 기반 표시/숨김
5. **쿼리 관리 개선**
- 쿼리 미리보기 개선 (테이블 형태)
- 쿼리 저장/불러오기
- 쿼리 템플릿
### Phase 3: 성능 및 보안
6. **성능 최적화**
- 쿼리 결과 캐싱
- 대용량 데이터 페이징
- 렌더링 최적화
- 이미지 레이지 로딩
7. **권한 관리**
- 리포트별 접근 권한
- 수정 권한 분리
- 템플릿 공유
- 사용자별 리포트 목록 필터링
---
## 기술 스택
### 백엔드
- Node.js + TypeScript
- Express.js
- PostgreSQL (raw SQL)
- pg (node-postgres)
### 프론트엔드
- Next.js 14 (App Router)
- React 18
- TypeScript
- Tailwind CSS
- Shadcn UI
- react-dnd (드래그 앤 드롭)
---
## 주요 아키텍처 결정
### 1. Context API 사용
- 리포트 디자이너의 복잡한 상태를 Context로 중앙 관리
- 컴포넌트 간 prop drilling 방지
### 2. Raw SQL 사용
- Prisma 대신 직접 SQL 작성
- 복잡한 쿼리와 트랜잭션 처리에 유리
- 데이터베이스 제어 수준 향상
### 3. JSON 기반 레이아웃 저장
- 레이아웃을 JSONB로 DB에 저장
- 버전 관리 용이
- 유연한 스키마
### 4. 쿼리 실행 결과 메모리 관리
- Context에 쿼리 결과 저장
- 컴포넌트에서 실시간 참조
- 불필요한 API 호출 방지
---
## 참고 문서
- [리포트*관리*시스템\_설계.md](./리포트_관리_시스템_설계.md) - 초기 설계 문서
- [레포트드자이너.html](../레포트드자이너.html) - 참조 프로토타입
---
## 다음 작업: 리포트 복사/삭제 테스트 및 검증
### 테스트 항목
1. **복사 기능 테스트**
- 리포트 복사 버튼 클릭
- 복사된 리포트명 확인 (원본명 + "\_copy")
- 복사된 리포트의 레이아웃 확인
- 복사된 리포트의 쿼리 확인
- 목록 자동 새로고침 확인
2. **삭제 기능 테스트**
- 삭제 버튼 클릭 시 확인 다이얼로그 표시
- 취소 버튼 동작 확인
- 삭제 실행 후 목록에서 제거 확인
- Toast 메시지 표시 확인
3. **에러 처리 테스트**
- 존재하지 않는 리포트 삭제 시도
- 네트워크 오류 시 Toast 메시지
- 로딩 중 버튼 비활성화 확인
### 추가 개선 사항
- [ ] 컴포넌트 복사 기능 (Ctrl+C/Ctrl+V)
- [ ] 다중 선택 및 정렬 기능
- [ ] 실행 취소/다시 실행 (Undo/Redo)
- [ ] 사용자 정의 템플릿 저장
---
**최종 업데이트**: 2025-10-01
**작성자**: AI Assistant
**상태**: 이미지 & 구분선 컴포넌트 완료 (기본 컴포넌트 완료, 약 99% 완료)

View File

@ -0,0 +1,679 @@
# 리포트 관리 시스템 설계
## 1. 프로젝트 개요
### 1.1 목적
ERP 시스템에서 다양한 업무 문서(발주서, 청구서, 거래명세서 등)를 동적으로 디자인하고 관리할 수 있는 리포트 관리 시스템을 구축합니다.
### 1.2 주요 기능
- 리포트 목록 조회 및 관리
- 드래그 앤 드롭 기반 리포트 디자이너
- 템플릿 관리 (기본 템플릿 + 사용자 정의 템플릿)
- 쿼리 관리 (마스터/디테일)
- 외부 DB 연동
- 인쇄 및 내보내기 (PDF, WORD)
- 미리보기 기능
## 2. 화면 구성
### 2.1 리포트 목록 화면 (`/admin/report`)
```
┌──────────────────────────────────────────────────────────────────┐
│ 리포트 관리 [+ 새 리포트] │
├──────────────────────────────────────────────────────────────────┤
│ 검색: [____________________] [검색] [초기화] │
├──────────────────────────────────────────────────────────────────┤
│ No │ 리포트명 │ 작성자 │ 수정일 │ 액션 │
├────┼──────────────┼────────┼───────────┼────────────────────────┤
│ 1 │ 발주서 양식 │ 홍길동 │ 2025-10-01 │ 수정 │ 복사 │ 삭제 │
│ 2 │ 청구서 기본 │ 김철수 │ 2025-09-28 │ 수정 │ 복사 │ 삭제 │
│ 3 │ 거래명세서 │ 이영희 │ 2025-09-25 │ 수정 │ 복사 │ 삭제 │
└──────────────────────────────────────────────────────────────────┘
```
**기능**
- 리포트 목록 조회 (페이징, 정렬, 검색)
- 새 리포트 생성
- 기존 리포트 수정
- 리포트 복사
- 리포트 삭제
- 리포트 미리보기
### 2.2 리포트 디자이너 화면
```
┌──────────────────────────────────────────────────────────────────┐
│ 리포트 디자이너 [저장] [미리보기] [초기화] [목록으로] │
├──────┬────────────────────────────────────────────────┬──────────┤
│ │ │ │
│ 템플릿│ 작업 영역 (캔버스) │ 속성 패널 │
│ │ │ │
│ 컴포넌트│ [드래그 앤 드롭] │ 쿼리 관리 │
│ │ │ │
│ │ │ DB 연동 │
└──────┴────────────────────────────────────────────────┴──────────┘
```
### 2.3 미리보기 모달
```
┌──────────────────────────────────────────────────────────────────┐
│ 미리보기 [닫기] │
├──────────────────────────────────────────────────────────────────┤
│ │
│ [리포트 내용 미리보기] │
│ │
├──────────────────────────────────────────────────────────────────┤
│ [인쇄] [PDF] [WORD] │
└──────────────────────────────────────────────────────────────────┘
```
## 3. 데이터베이스 설계
### 3.1 테이블 구조
#### REPORT_TEMPLATE (리포트 템플릿)
```sql
CREATE TABLE report_template (
template_id VARCHAR(50) PRIMARY KEY, -- 템플릿 ID
template_name_kor VARCHAR(100) NOT NULL, -- 템플릿명 (한국어)
template_name_eng VARCHAR(100), -- 템플릿명 (영어)
template_type VARCHAR(30) NOT NULL, -- 템플릿 타입 (ORDER, INVOICE, STATEMENT, etc)
is_system CHAR(1) DEFAULT 'N', -- 시스템 기본 템플릿 여부 (Y/N)
thumbnail_url VARCHAR(500), -- 썸네일 이미지 경로
description TEXT, -- 템플릿 설명
layout_config TEXT, -- 레이아웃 설정 (JSON)
default_queries TEXT, -- 기본 쿼리 (JSON)
use_yn CHAR(1) DEFAULT 'Y', -- 사용 여부
sort_order INTEGER DEFAULT 0, -- 정렬 순서
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_at TIMESTAMP,
updated_by VARCHAR(50)
);
```
#### REPORT_MASTER (리포트 마스터)
```sql
CREATE TABLE report_master (
report_id VARCHAR(50) PRIMARY KEY, -- 리포트 ID
report_name_kor VARCHAR(100) NOT NULL, -- 리포트명 (한국어)
report_name_eng VARCHAR(100), -- 리포트명 (영어)
template_id VARCHAR(50), -- 템플릿 ID (FK)
report_type VARCHAR(30) NOT NULL, -- 리포트 타입
company_code VARCHAR(20), -- 회사 코드
description TEXT, -- 설명
use_yn CHAR(1) DEFAULT 'Y', -- 사용 여부
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_at TIMESTAMP,
updated_by VARCHAR(50),
FOREIGN KEY (template_id) REFERENCES report_template(template_id)
);
```
#### REPORT_LAYOUT (리포트 레이아웃)
```sql
CREATE TABLE report_layout (
layout_id VARCHAR(50) PRIMARY KEY, -- 레이아웃 ID
report_id VARCHAR(50) NOT NULL, -- 리포트 ID (FK)
canvas_width INTEGER DEFAULT 210, -- 캔버스 너비 (mm)
canvas_height INTEGER DEFAULT 297, -- 캔버스 높이 (mm)
page_orientation VARCHAR(10) DEFAULT 'portrait', -- 페이지 방향 (portrait/landscape)
margin_top INTEGER DEFAULT 20, -- 상단 여백 (mm)
margin_bottom INTEGER DEFAULT 20, -- 하단 여백 (mm)
margin_left INTEGER DEFAULT 20, -- 좌측 여백 (mm)
margin_right INTEGER DEFAULT 20, -- 우측 여백 (mm)
components TEXT, -- 컴포넌트 배치 정보 (JSON)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_at TIMESTAMP,
updated_by VARCHAR(50),
FOREIGN KEY (report_id) REFERENCES report_master(report_id)
);
```
## 4. 컴포넌트 목록
### 4.1 기본 컴포넌트
#### 텍스트 관련
- **Text Field**: 단일 라인 텍스트 입력/표시
- **Text Area**: 여러 줄 텍스트 입력/표시
- **Label**: 고정 라벨 텍스트
- **Rich Text**: 서식이 있는 텍스트 (굵게, 기울임, 색상)
#### 숫자/날짜 관련
- **Number**: 숫자 표시 (통화 형식 지원)
- **Date**: 날짜 표시 (형식 지정 가능)
- **Date Time**: 날짜 + 시간 표시
- **Calculate Field**: 계산 필드 (합계, 평균 등)
#### 테이블/그리드
- **Data Table**: 데이터 테이블 (디테일 쿼리 바인딩)
- **Summary Table**: 요약 테이블
- **Group Table**: 그룹핑 테이블
#### 이미지/그래픽
- **Image**: 이미지 표시 (로고, 서명 등)
- **Line**: 구분선
- **Rectangle**: 사각형 (테두리)
#### 특수 컴포넌트
- **Page Number**: 페이지 번호
- **Current Date**: 현재 날짜/시간
- **Company Info**: 회사 정보 (자동)
- **Signature**: 서명란
- **Stamp**: 도장란
### 4.2 컴포넌트 속성
각 컴포넌트는 다음 공통 속성을 가집니다:
```typescript
interface ComponentBase {
id: string; // 컴포넌트 ID
type: string; // 컴포넌트 타입
x: number; // X 좌표
y: number; // Y 좌표
width: number; // 너비
height: number; // 높이
zIndex: number; // Z-인덱스
// 스타일
fontSize?: number; // 글자 크기
fontFamily?: string; // 폰트
fontWeight?: string; // 글자 굵기
fontColor?: string; // 글자 색상
backgroundColor?: string; // 배경색
borderWidth?: number; // 테두리 두께
borderColor?: string; // 테두리 색상
borderRadius?: number; // 모서리 둥글기
textAlign?: string; // 텍스트 정렬
padding?: number; // 내부 여백
// 데이터 바인딩
queryId?: string; // 연결된 쿼리 ID
fieldName?: string; // 필드명
defaultValue?: string; // 기본값
format?: string; // 표시 형식
// 기타
visible?: boolean; // 표시 여부
printable?: boolean; // 인쇄 여부
conditional?: string; // 조건부 표시 (수식)
}
```
## 5. 템플릿 목록
### 5.1 기본 템플릿 (시스템)
#### 구매/발주 관련
- **발주서 (Purchase Order)**: 거래처에 발주하는 문서
- **구매요청서 (Purchase Request)**: 내부 구매 요청 문서
- **발주 확인서 (PO Confirmation)**: 발주 확인 문서
#### 판매/청구 관련
- **청구서 (Invoice)**: 고객에게 청구하는 문서
- **견적서 (Quotation)**: 견적 제공 문서
- **거래명세서 (Transaction Statement)**: 거래 내역 명세
- **세금계산서 (Tax Invoice)**: 세금 계산서
- **영수증 (Receipt)**: 영수 증빙 문서
#### 재고/입출고 관련
- **입고증 (Goods Receipt)**: 입고 증빙 문서
- **출고증 (Delivery Note)**: 출고 증빙 문서
- **재고 현황표 (Inventory Report)**: 재고 현황
- **이동 전표 (Transfer Note)**: 재고 이동 문서
#### 생산 관련
- **작업지시서 (Work Order)**: 생산 작업 지시
- **생산 일보 (Production Daily Report)**: 생산 일일 보고
- **품질 검사표 (Quality Inspection)**: 품질 검사 기록
- **불량 보고서 (Defect Report)**: 불량 보고
#### 회계/경영 관련
- **손익 계산서 (Income Statement)**: 손익 현황
- **대차대조표 (Balance Sheet)**: 재무 상태
- **현금 흐름표 (Cash Flow Statement)**: 현금 흐름
- **급여 명세서 (Payroll Slip)**: 급여 내역
#### 일반 문서
- **기본 양식 (Basic Template)**: 빈 캔버스
- **일반 보고서 (General Report)**: 일반 보고 양식
- **목록 양식 (List Template)**: 목록형 양식
### 5.2 사용자 정의 템플릿
- 사용자가 직접 생성한 템플릿
- 기본 템플릿을 복사하여 수정 가능
- 회사별로 관리 가능
## 6. API 설계
### 6.1 리포트 목록 API
#### GET `/api/admin/reports`
리포트 목록 조회
```typescript
// Request
interface GetReportsRequest {
page?: number;
limit?: number;
searchText?: string;
reportType?: string;
useYn?: string;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
// Response
interface GetReportsResponse {
items: ReportMaster[];
total: number;
page: number;
limit: number;
}
```
#### GET `/api/admin/reports/:reportId`
리포트 상세 조회
```typescript
// Response
interface ReportDetail {
report: ReportMaster;
layout: ReportLayout;
queries: ReportQuery[];
components: Component[];
}
```
#### POST `/api/admin/reports`
리포트 생성
```typescript
// Request
interface CreateReportRequest {
reportNameKor: string;
reportNameEng?: string;
templateId?: string;
reportType: string;
description?: string;
}
// Response
interface CreateReportResponse {
reportId: string;
message: string;
}
```
#### PUT `/api/admin/reports/:reportId`
리포트 수정
```typescript
// Request
interface UpdateReportRequest {
reportNameKor?: string;
reportNameEng?: string;
reportType?: string;
description?: string;
useYn?: string;
}
```
#### DELETE `/api/admin/reports/:reportId`
리포트 삭제
#### POST `/api/admin/reports/:reportId/copy`
리포트 복사
### 6.2 템플릿 API
#### GET `/api/admin/reports/templates`
템플릿 목록 조회
```typescript
// Response
interface GetTemplatesResponse {
system: ReportTemplate[]; // 시스템 템플릿
custom: ReportTemplate[]; // 사용자 정의 템플릿
}
```
#### POST `/api/admin/reports/templates`
템플릿 생성 (사용자 정의)
```typescript
// Request
interface CreateTemplateRequest {
templateNameKor: string;
templateNameEng?: string;
templateType: string;
description?: string;
layoutConfig: any;
defaultQueries?: any;
}
```
#### PUT `/api/admin/reports/templates/:templateId`
템플릿 수정
#### DELETE `/api/admin/reports/templates/:templateId`
템플릿 삭제
### 6.3 레이아웃 API
#### GET `/api/admin/reports/:reportId/layout`
레이아웃 조회
#### PUT `/api/admin/reports/:reportId/layout`
레이아웃 저장
```typescript
// Request
interface SaveLayoutRequest {
canvasWidth: number;
canvasHeight: number;
pageOrientation: string;
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
components: Component[];
}
```
### 6.4 인쇄/내보내기 API
#### POST `/api/admin/reports/:reportId/preview`
미리보기 생성
```typescript
// Request
interface PreviewRequest {
parameters?: { [key: string]: any };
format?: "HTML" | "PDF";
}
// Response
interface PreviewResponse {
html?: string; // HTML 미리보기
pdfUrl?: string; // PDF URL
}
```
#### POST `/api/admin/reports/:reportId/print`
인쇄 (PDF 생성)
```typescript
// Request
interface PrintRequest {
parameters?: { [key: string]: any };
format: "PDF" | "WORD" | "EXCEL";
}
// Response
interface PrintResponse {
fileUrl: string;
fileName: string;
fileSize: number;
}
```
## 7. 프론트엔드 구조
### 7.1 페이지 구조
```
/admin/report
├── ReportListPage.tsx # 리포트 목록 페이지
├── ReportDesignerPage.tsx # 리포트 디자이너 페이지
└── components/
├── ReportList.tsx # 리포트 목록 테이블
├── ReportSearchForm.tsx # 검색 폼
├── TemplateSelector.tsx # 템플릿 선택기
├── ComponentPalette.tsx # 컴포넌트 팔레트
├── Canvas.tsx # 캔버스 영역
├── ComponentRenderer.tsx # 컴포넌트 렌더러
├── PropertyPanel.tsx # 속성 패널
├── QueryManager.tsx # 쿼리 관리
├── QueryCard.tsx # 쿼리 카드
├── ConnectionManager.tsx # 외부 DB 연결 관리
├── PreviewModal.tsx # 미리보기 모달
└── PrintOptionsModal.tsx # 인쇄 옵션 모달
```
### 7.2 상태 관리
```typescript
interface ReportDesignerState {
// 리포트 기본 정보
report: ReportMaster | null;
// 레이아웃
layout: ReportLayout | null;
components: Component[];
selectedComponentId: string | null;
// 쿼리
queries: ReportQuery[];
queryResults: { [queryId: string]: any[] };
// 외부 연결
connections: ReportExternalConnection[];
// UI 상태
isDragging: boolean;
isResizing: boolean;
showPreview: boolean;
showPrintOptions: boolean;
// 히스토리 (Undo/Redo)
history: {
past: Component[][];
present: Component[];
future: Component[][];
};
}
```
## 8. 구현 우선순위
### Phase 1: 기본 기능 (2주)
- [ ] 데이터베이스 테이블 생성
- [ ] 리포트 목록 화면
- [ ] 리포트 CRUD API
- [ ] 템플릿 목록 조회
- [ ] 기본 템플릿 데이터 생성
### Phase 2: 디자이너 기본 (2주)
- [ ] 캔버스 구현
- [ ] 컴포넌트 드래그 앤 드롭
- [ ] 컴포넌트 선택/이동/크기 조절
- [ ] 속성 패널 (기본)
- [ ] 저장/불러오기
### Phase 3: 쿼리 관리 (1주)
- [ ] 쿼리 추가/수정/삭제
- [ ] 파라미터 감지 및 입력
- [ ] 쿼리 실행 (내부 DB)
- [ ] 쿼리 결과를 컴포넌트에 바인딩
### Phase 4: 쿼리 관리 고급 (1주)
- [ ] 쿼리 필드 매핑
- [ ] 컴포넌트와 데이터 바인딩
- [ ] 파라미터 전달 및 처리
### Phase 5: 미리보기/인쇄 (1주)
- [ ] HTML 미리보기
- [ ] PDF 생성
- [ ] WORD 생성
- [ ] 브라우저 인쇄
### Phase 6: 고급 기능 (2주)
- [ ] 템플릿 생성 기능
- [ ] 컴포넌트 추가 (이미지, 서명, 도장)
- [ ] 계산 필드
- [ ] 조건부 표시
- [ ] Undo/Redo
- [ ] 다국어 지원
## 9. 기술 스택
### Backend
- **Node.js + TypeScript**: 백엔드 서버
- **PostgreSQL**: 데이터베이스
- **Prisma**: ORM
- **Puppeteer**: PDF 생성
- **docx**: WORD 생성
### Frontend
- **Next.js + React**: 프론트엔드 프레임워크
- **TypeScript**: 타입 안정성
- **TailwindCSS**: 스타일링
- **react-dnd**: 드래그 앤 드롭
- **react-grid-layout**: 레이아웃 관리
- **react-to-print**: 인쇄 기능
- **react-pdf**: PDF 미리보기
## 10. 보안 고려사항
### 10.1 쿼리 실행 보안
- SELECT 쿼리만 허용 (INSERT, UPDATE, DELETE 금지)
- 쿼리 결과 크기 제한 (최대 1000 rows)
- 실행 시간 제한 (30초)
- SQL 인젝션 방지 (파라미터 바인딩 강제)
- 위험한 함수 차단 (DROP, TRUNCATE 등)
### 10.2 파일 보안
- 생성된 PDF/WORD 파일은 임시 디렉토리에 저장
- 파일은 24시간 후 자동 삭제
- 파일 다운로드 시 토큰 검증
### 10.3 접근 권한
- 리포트 생성/수정/삭제 권한 체크
- 관리자만 템플릿 생성 가능
- 사용자별 리포트 접근 제어
## 11. 성능 최적화
### 11.1 PDF 생성 최적화
- 백그라운드 작업으로 처리
- 생성된 PDF는 CDN에 캐싱
### 11.2 프론트엔드 최적화
- 컴포넌트 가상화 (많은 컴포넌트 처리)
- 디바운싱/쓰로틀링 (드래그 앤 드롭)
- 이미지 레이지 로딩
### 11.3 데이터베이스 최적화
- 레이아웃 데이터는 JSON 형태로 저장
- 리포트 목록 조회 시 인덱스 활용
- 자주 사용하는 템플릿 캐싱
## 12. 테스트 계획
### 12.1 단위 테스트
- API 엔드포인트 테스트
- 쿼리 파싱 테스트
- PDF 생성 테스트
### 12.2 통합 테스트
- 리포트 생성 → 쿼리 실행 → PDF 생성 전체 플로우
- 템플릿 적용 → 데이터 바인딩 테스트
### 12.3 UI 테스트
- 드래그 앤 드롭 동작 테스트
- 컴포넌트 속성 변경 테스트
## 13. 향후 확장 계획
### 13.1 고급 기능
- 차트/그래프 컴포넌트
- 조건부 서식 (색상 변경 등)
- 그룹핑 및 집계 함수
- 마스터-디테일 관계 자동 설정
### 13.2 협업 기능
- 리포트 공유
- 버전 관리
- 댓글 기능
### 13.3 자동화
- 스케줄링 (정기적 리포트 생성)
- 이메일 자동 발송
- 알림 설정
## 14. 참고 자료
### 14.1 유사 솔루션
- Crystal Reports
- JasperReports
- BIRT (Business Intelligence and Reporting Tools)
- FastReport
### 14.2 라이브러리
- [react-grid-layout](https://github.com/react-grid-layout/react-grid-layout)
- [react-dnd](https://react-dnd.github.io/react-dnd/)
- [puppeteer](https://pptr.dev/)
- [docx](https://docx.js.org/)

View File

@ -0,0 +1,371 @@
# 리포트 문서 번호 자동 채번 시스템 설계
## 1. 개요
리포트 관리 시스템에 체계적인 문서 번호 자동 채번 시스템을 추가하여, 기업 환경에서 문서를 추적하고 관리할 수 있도록 합니다.
## 2. 문서 번호 형식
### 2.1 기본 형식
```
{PREFIX}-{YEAR}-{SEQUENCE}
예: RPT-2024-0001, INV-2024-0123
```
### 2.2 확장 형식 (선택 사항)
```
{PREFIX}-{DEPT_CODE}-{YEAR}-{SEQUENCE}
예: RPT-SALES-2024-0001, INV-FIN-2024-0123
```
### 2.3 구성 요소
- **PREFIX**: 문서 유형 접두사 (예: RPT, INV, PO, QT)
- **DEPT_CODE**: 부서 코드 (선택 사항)
- **YEAR**: 연도 (4자리)
- **SEQUENCE**: 순차 번호 (0001부터 시작, 자릿수 설정 가능)
## 3. 데이터베이스 스키마
### 3.1 문서 번호 규칙 테이블
```sql
-- 문서 번호 규칙 정의
CREATE TABLE report_number_rules (
rule_id SERIAL PRIMARY KEY,
rule_name VARCHAR(100) NOT NULL, -- 규칙 이름
prefix VARCHAR(20) NOT NULL, -- 접두사 (RPT, INV 등)
use_dept_code BOOLEAN DEFAULT FALSE, -- 부서 코드 사용 여부
use_year BOOLEAN DEFAULT TRUE, -- 연도 사용 여부
sequence_length INTEGER DEFAULT 4, -- 순차 번호 자릿수
reset_period VARCHAR(20) DEFAULT 'YEARLY', -- 초기화 주기 (YEARLY, MONTHLY, NEVER)
separator VARCHAR(5) DEFAULT '-', -- 구분자
description TEXT, -- 설명
is_active BOOLEAN DEFAULT TRUE, -- 활성화 여부
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_by VARCHAR(50)
);
-- 기본 데이터 삽입
INSERT INTO report_number_rules (rule_name, prefix, description)
VALUES ('리포트 문서 번호', 'RPT', '일반 리포트 문서 번호 규칙');
```
### 3.2 문서 번호 시퀀스 테이블
```sql
-- 문서 번호 시퀀스 관리 (연도/부서별 현재 번호)
CREATE TABLE report_number_sequences (
sequence_id SERIAL PRIMARY KEY,
rule_id INTEGER NOT NULL REFERENCES report_number_rules(rule_id),
dept_code VARCHAR(20), -- 부서 코드 (NULL 가능)
year INTEGER NOT NULL, -- 연도
current_number INTEGER DEFAULT 0, -- 현재 번호
last_generated_at TIMESTAMP, -- 마지막 생성 시각
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (rule_id, dept_code, year) -- 규칙+부서+연도 조합 유니크
);
```
### 3.3 리포트 테이블 수정
```sql
-- 기존 report_layout 테이블에 컬럼 추가
ALTER TABLE report_layout
ADD COLUMN document_number VARCHAR(100), -- 생성된 문서 번호
ADD COLUMN number_rule_id INTEGER REFERENCES report_number_rules(rule_id), -- 사용된 규칙
ADD COLUMN number_generated_at TIMESTAMP; -- 번호 생성 시각
-- 문서 번호 인덱스 (검색 성능)
CREATE INDEX idx_report_layout_document_number ON report_layout(document_number);
```
### 3.4 문서 번호 이력 테이블 (감사용)
```sql
-- 문서 번호 생성 이력
CREATE TABLE report_number_history (
history_id SERIAL PRIMARY KEY,
report_id INTEGER REFERENCES report_layout(id),
document_number VARCHAR(100) NOT NULL,
rule_id INTEGER REFERENCES report_number_rules(rule_id),
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
generated_by VARCHAR(50),
is_voided BOOLEAN DEFAULT FALSE, -- 번호 무효화 여부
void_reason TEXT, -- 무효화 사유
voided_at TIMESTAMP,
voided_by VARCHAR(50)
);
-- 문서 번호로 검색 인덱스
CREATE INDEX idx_report_number_history_doc_number ON report_number_history(document_number);
```
## 4. 백엔드 구현
### 4.1 서비스 레이어 (`reportNumberService.ts`)
```typescript
export class ReportNumberService {
// 문서 번호 생성
static async generateNumber(
ruleId: number,
deptCode?: string
): Promise<string>;
// 문서 번호 형식 검증
static async validateNumber(documentNumber: string): Promise<boolean>;
// 문서 번호 중복 체크
static async isDuplicate(documentNumber: string): Promise<boolean>;
// 문서 번호 무효화
static async voidNumber(
documentNumber: string,
reason: string,
userId: string
): Promise<void>;
// 특정 규칙의 다음 번호 미리보기
static async previewNextNumber(
ruleId: number,
deptCode?: string
): Promise<string>;
}
```
### 4.2 컨트롤러 (`reportNumberController.ts`)
```typescript
// GET /api/report/number-rules - 규칙 목록
// GET /api/report/number-rules/:id - 규칙 상세
// POST /api/report/number-rules - 규칙 생성
// PUT /api/report/number-rules/:id - 규칙 수정
// DELETE /api/report/number-rules/:id - 규칙 삭제
// POST /api/report/:reportId/generate-number - 문서 번호 생성
// POST /api/report/number/preview - 다음 번호 미리보기
// POST /api/report/number/void - 문서 번호 무효화
// GET /api/report/number/history/:documentNumber - 문서 번호 이력
```
### 4.3 핵심 로직 (번호 생성)
```typescript
async generateNumber(ruleId: number, deptCode?: string): Promise<string> {
// 1. 트랜잭션 시작
const client = await pool.connect();
try {
await client.query('BEGIN');
// 2. 규칙 조회
const rule = await this.getRule(ruleId);
// 3. 현재 연도/월
const now = new Date();
const year = now.getFullYear();
// 4. 시퀀스 조회 또는 생성 (FOR UPDATE로 락)
let sequence = await this.getSequence(ruleId, deptCode, year, true);
if (!sequence) {
sequence = await this.createSequence(ruleId, deptCode, year);
}
// 5. 다음 번호 계산
const nextNumber = sequence.current_number + 1;
// 6. 문서 번호 생성
const documentNumber = this.formatNumber(rule, deptCode, year, nextNumber);
// 7. 시퀀스 업데이트
await this.updateSequence(sequence.sequence_id, nextNumber);
// 8. 커밋
await client.query('COMMIT');
return documentNumber;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// 번호 포맷팅
private formatNumber(
rule: NumberRule,
deptCode: string | undefined,
year: number,
sequence: number
): string {
const parts = [rule.prefix];
if (rule.use_dept_code && deptCode) {
parts.push(deptCode);
}
if (rule.use_year) {
parts.push(year.toString());
}
// 0 패딩
const paddedSequence = sequence.toString().padStart(rule.sequence_length, '0');
parts.push(paddedSequence);
return parts.join(rule.separator);
}
```
## 5. 프론트엔드 구현
### 5.1 문서 번호 규칙 관리 화면
**경로**: `/admin/report/number-rules`
**기능**:
- 규칙 목록 조회
- 규칙 생성/수정/삭제
- 규칙 미리보기 (다음 번호 확인)
- 규칙 활성화/비활성화
### 5.2 리포트 목록 화면 수정
**변경 사항**:
- 문서 번호 컬럼 추가
- 문서 번호로 검색 기능
### 5.3 리포트 저장 시 번호 생성
**위치**: `ReportDesignerContext.tsx` - `saveLayout` 함수
```typescript
const saveLayout = async () => {
// 1. 새 리포트인 경우 문서 번호 자동 생성
if (reportId === "new" && !documentNumber) {
const response = await fetch(`/api/report/generate-number`, {
method: "POST",
body: JSON.stringify({ ruleId: 1 }), // 기본 규칙
});
const { documentNumber: newNumber } = await response.json();
setDocumentNumber(newNumber);
}
// 2. 리포트 저장 (문서 번호 포함)
await saveReport({ ...reportData, documentNumber });
};
```
### 5.4 문서 번호 표시 UI
**위치**: 디자이너 헤더
```tsx
<div className="document-number">
<Label>문서 번호</Label>
<Badge variant="outline">{documentNumber || "저장 시 자동 생성"}</Badge>
</div>
```
## 6. 동시성 제어
### 6.1 문제점
여러 사용자가 동시에 문서 번호를 생성할 때 중복 발생 가능성
### 6.2 해결 방법
**PostgreSQL의 `FOR UPDATE` 사용**
```sql
-- 시퀀스 조회 시 행 락 걸기
SELECT * FROM report_number_sequences
WHERE rule_id = $1 AND year = $2
FOR UPDATE;
```
**트랜잭션 격리 수준**
```typescript
await client.query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");
```
## 7. 테스트 시나리오
### 7.1 기본 기능 테스트
- [ ] 규칙 생성 → 문서 번호 생성 → 포맷 확인
- [ ] 연속 생성 시 순차 번호 증가 확인
- [ ] 연도 변경 시 시퀀스 초기화 확인
### 7.2 동시성 테스트
- [ ] 10명이 동시에 문서 번호 생성 → 중복 없음 확인
- [ ] 동일 규칙으로 100개 생성 → 순차 번호 연속성 확인
### 7.3 에러 처리
- [ ] 존재하지 않는 규칙 ID → 에러 메시지
- [ ] 비활성화된 규칙 사용 → 경고 메시지
- [ ] 시퀀스 최대값 초과 → 관리자 알림
## 8. 구현 순서
### Phase 1: 데이터베이스 (1단계)
1. 테이블 생성 SQL 작성
2. 마이그레이션 실행
3. 기본 데이터 삽입
### Phase 2: 백엔드 (2단계)
1. `reportNumberService.ts` 구현
2. `reportNumberController.ts` 구현
3. 라우트 추가
4. 단위 테스트
### Phase 3: 프론트엔드 (3단계)
1. 문서 번호 규칙 관리 화면
2. 리포트 목록 화면 수정
3. 디자이너 문서 번호 표시
4. 저장 시 자동 생성 연동
### Phase 4: 테스트 및 최적화 (4단계)
1. 통합 테스트
2. 동시성 테스트
3. 성능 최적화
4. 사용자 가이드 작성
## 9. 향후 확장
### 9.1 고급 기능
- 문서 번호 예약 기능
- 번호 건너뛰기 허용 설정
- 커스텀 포맷 지원 (정규식 기반)
- 연/월/일 단위 초기화 선택
### 9.2 통합
- 승인 완료 시점에 최종 번호 확정
- 외부 시스템과 번호 동기화
- 바코드/QR 코드 자동 생성
## 10. 보안 고려사항
- 문서 번호 생성 권한 제한
- 번호 무효화 감사 로그
- 시퀀스 직접 수정 방지
- API 호출 횟수 제한 (Rate Limiting)

View File

@ -0,0 +1,388 @@
# 리포트 페이지 관리 시스템 설계
## 1. 개요
리포트 디자이너에 다중 페이지 관리 기능을 추가하여 여러 페이지에 걸친 복잡한 문서를 작성할 수 있도록 합니다.
## 2. 주요 기능
### 2.1 페이지 관리
- 페이지 추가/삭제
- 페이지 복사
- 페이지 순서 변경 (드래그 앤 드롭)
- 페이지 이름 지정
### 2.2 페이지 네비게이션
- 좌측 페이지 썸네일 패널
- 페이지 간 전환 (클릭)
- 이전/다음 페이지 이동
- 페이지 번호 표시
### 2.3 페이지별 설정
- 페이지 크기 (A4, A3, Letter, 사용자 정의)
- 페이지 방향 (세로/가로)
- 여백 설정
- 배경색
### 2.4 컴포넌트 관리
- 컴포넌트는 특정 페이지에 속함
- 페이지 간 컴포넌트 복사/이동
- 현재 페이지의 컴포넌트만 표시
## 3. 데이터베이스 스키마
### 3.1 기존 구조 활용 (변경 없음)
**report_layout 테이블의 layout_config (JSONB) 활용**
기존:
```json
{
"width": 210,
"height": 297,
"orientation": "portrait",
"components": [...]
}
```
변경 후:
```json
{
"pages": [
{
"page_id": "page-uuid-1",
"page_name": "표지",
"page_order": 0,
"width": 210,
"height": 297,
"orientation": "portrait",
"margins": {
"top": 20,
"bottom": 20,
"left": 20,
"right": 20
},
"background_color": "#ffffff",
"components": [
{
"id": "comp-1",
"type": "text",
"x": 100,
"y": 50,
...
}
]
},
{
"page_id": "page-uuid-2",
"page_name": "본문",
"page_order": 1,
"width": 210,
"height": 297,
"orientation": "portrait",
"margins": { "top": 20, "bottom": 20, "left": 20, "right": 20 },
"background_color": "#ffffff",
"components": [...]
}
]
}
```
### 3.2 마이그레이션 전략
기존 단일 페이지 리포트 자동 변환:
```typescript
// 기존 구조 감지 시
if (layoutConfig.components && !layoutConfig.pages) {
// 자동으로 pages 구조로 변환
layoutConfig = {
pages: [
{
page_id: uuidv4(),
page_name: "페이지 1",
page_order: 0,
width: layoutConfig.width || 210,
height: layoutConfig.height || 297,
orientation: layoutConfig.orientation || "portrait",
margins: { top: 20, bottom: 20, left: 20, right: 20 },
background_color: "#ffffff",
components: layoutConfig.components,
},
],
};
}
```
## 4. 프론트엔드 구조
### 4.1 타입 정의 (types/report.ts)
```typescript
export interface ReportPage {
page_id: string;
report_id: string;
page_order: number;
page_name: string;
// 페이지 설정
width: number;
height: number;
orientation: 'portrait' | 'landscape';
// 여백
margin_top: number;
margin_bottom: number;
margin_left: number;
margin_right: number;
// 배경
background_color: string;
created_at?: string;
updated_at?: string;
}
export interface ComponentConfig {
id: string;
// page_id 불필요 (페이지의 components 배열에 포함됨)
type: 'text' | 'label' | 'image' | 'table' | ...;
x: number;
y: number;
width: number;
height: number;
// ... 기타 속성
}
export interface ReportLayoutConfig {
pages: ReportPage[];
}
```
### 4.2 Context 구조 변경
```typescript
interface ReportDesignerContextType {
// 페이지 관리
pages: ReportPage[];
currentPageId: string | null;
currentPage: ReportPage | null;
addPage: () => void;
deletePage: (pageId: string) => void;
duplicatePage: (pageId: string) => void;
reorderPages: (sourceIndex: number, targetIndex: number) => void;
selectPage: (pageId: string) => void;
updatePage: (pageId: string, updates: Partial<ReportPage>) => void;
// 컴포넌트 (현재 페이지만)
currentPageComponents: ComponentConfig[];
// ... 기존 기능들
}
```
### 4.3 UI 구조
```
┌─────────────────────────────────────────────────────────────┐
│ ReportDesignerToolbar (저장, 미리보기, 페이지 추가 등) │
├──────────┬────────────────────────────────────┬─────────────┤
│ │ │ │
│ PageList │ ReportDesignerCanvas │ Right │
│ (좌측) │ (현재 페이지만 표시) │ Panel │
│ │ │ (속성) │
│ - Page 1 │ ┌──────────────────────────┐ │ │
│ - Page 2 │ │ │ │ │
│ * Page 3 │ │ [컴포넌트들] │ │ │
│ (현재) │ │ │ │ │
│ │ └──────────────────────────┘ │ │
│ [+ 추가] │ │ │
│ │ 이전 | 다음 (페이지 네비게이션) │ │
└──────────┴────────────────────────────────────┴─────────────┘
```
## 5. 컴포넌트 구조
### 5.1 새 컴포넌트
#### PageListPanel.tsx
```typescript
- 좌측 페이지 목록 패널
- 페이지 썸네일 표시
- 드래그 앤 드롭으로 순서 변경
- 페이지 추가/삭제/복사 버튼
- 현재 페이지 하이라이트
```
#### PageNavigator.tsx
```typescript
- 캔버스 하단의 페이지 네비게이션
- 이전/다음 버튼
- 현재 페이지 번호 표시
- 페이지 점프 (1/5 형식)
```
#### PageSettingsPanel.tsx
```typescript
- 우측 패널 내 페이지 설정 섹션
- 페이지 크기, 방향
- 여백 설정
- 배경색
```
### 5.2 수정할 컴포넌트
#### ReportDesignerContext.tsx
- pages 상태 추가
- currentPageId 상태 추가
- 페이지 관리 함수들 추가
- components를 currentPageComponents로 필터링
#### ReportDesignerCanvas.tsx
- currentPageComponents만 렌더링
- 캔버스 크기를 currentPage 기준으로 설정
- 컴포넌트 추가 시 page_id 포함
#### ReportDesignerToolbar.tsx
- "페이지 추가" 버튼 추가
- 저장 시 pages도 함께 저장
#### ReportPreviewModal.tsx
- 모든 페이지 순서대로 미리보기
- 페이지 구분선 표시
- PDF 저장 시 모든 페이지 포함
## 6. API 엔드포인트
### 6.1 페이지 관리
```typescript
// 페이지 목록 조회
GET /api/report/:reportId/pages
Response: { pages: ReportPage[] }
// 페이지 생성
POST /api/report/:reportId/pages
Body: { page_name, width, height, orientation, margins }
Response: { page: ReportPage }
// 페이지 수정
PUT /api/report/pages/:pageId
Body: Partial<ReportPage>
Response: { page: ReportPage }
// 페이지 삭제
DELETE /api/report/pages/:pageId
Response: { success: boolean }
// 페이지 순서 변경
PUT /api/report/:reportId/pages/reorder
Body: { pageOrders: Array<{ page_id, page_order }> }
Response: { success: boolean }
// 페이지 복사
POST /api/report/pages/:pageId/duplicate
Response: { page: ReportPage }
```
### 6.2 레이아웃 (기존 수정)
```typescript
// 레이아웃 저장 (페이지별)
PUT /api/report/:reportId/layout
Body: {
pages: ReportPage[],
components: ComponentConfig[] // page_id 포함
}
```
## 7. 구현 단계
### Phase 1: DB 및 백엔드 (0.5일)
1. ✅ DB 스키마 생성
2. ✅ API 엔드포인트 구현
3. ✅ 기존 리포트 마이그레이션 (단일 페이지 생성)
### Phase 2: 타입 및 Context (0.5일)
1. ✅ 타입 정의 업데이트
2. ✅ Context에 페이지 상태/함수 추가
3. ✅ API 연동
### Phase 3: UI 컴포넌트 (1일)
1. ✅ PageListPanel 구현
2. ✅ PageNavigator 구현
3. ✅ PageSettingsPanel 구현
### Phase 4: 통합 및 수정 (1일)
1. ✅ Canvas에서 현재 페이지만 표시
2. ✅ 컴포넌트 추가/수정 시 page_id 처리
3. ✅ 미리보기에서 모든 페이지 표시
4. ✅ PDF/WORD 저장에서 모든 페이지 처리
### Phase 5: 테스트 및 최적화 (0.5일)
1. ✅ 페이지 전환 성능 확인
2. ✅ 썸네일 렌더링 최적화
3. ✅ 버그 수정
**총 예상 기간: 3-4일**
## 8. 주의사항
### 8.1 성능 최적화
- 페이지 썸네일은 저해상도로 렌더링
- 현재 페이지 컴포넌트만 DOM에 유지
- 페이지 전환 시 애니메이션 최소화
### 8.2 호환성
- 기존 리포트는 자동으로 단일 페이지로 마이그레이션
- 템플릿도 페이지 구조 포함
### 8.3 사용자 경험
- 페이지 삭제 시 확인 다이얼로그
- 컴포넌트가 있는 페이지 삭제 시 경고
- 페이지 순서 변경 시 즉시 반영
## 9. 추후 확장 기능
### 9.1 페이지 템플릿
- 자주 사용하는 페이지 레이아웃 저장
- 페이지 추가 시 템플릿 선택
### 9.2 마스터 페이지
- 모든 페이지에 공통으로 적용되는 헤더/푸터
- 페이지 번호 자동 삽입
### 9.3 페이지 연결
- 테이블 데이터가 여러 페이지에 자동 분할
- 페이지 오버플로우 처리
## 10. 참고 자료
- 오즈리포트 메뉴얼
- Crystal Reports 페이지 관리
- Adobe InDesign 페이지 시스템

View File

@ -0,0 +1,92 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { ReportDesignerToolbar } from "@/components/report/designer/ReportDesignerToolbar";
import { PageListPanel } from "@/components/report/designer/PageListPanel";
import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel";
import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas";
import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel";
import { ReportDesignerProvider } from "@/contexts/ReportDesignerContext";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { Loader2 } from "lucide-react";
export default function ReportDesignerPage() {
const params = useParams();
const router = useRouter();
const reportId = params.reportId as string;
const [isLoading, setIsLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
const loadReport = async () => {
// 'new'는 새 리포트 생성 모드
if (reportId === "new") {
setIsLoading(false);
return;
}
try {
const response = await reportApi.getReportById(reportId);
if (!response.success) {
toast({
title: "오류",
description: "리포트를 찾을 수 없습니다.",
variant: "destructive",
});
router.push("/admin/report");
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트를 불러오는데 실패했습니다.",
variant: "destructive",
});
router.push("/admin/report");
} finally {
setIsLoading(false);
}
};
if (reportId) {
loadReport();
}
}, [reportId, router, toast]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
);
}
return (
<DndProvider backend={HTML5Backend}>
<ReportDesignerProvider reportId={reportId}>
<div className="flex h-screen flex-col overflow-hidden bg-gray-50">
{/* 상단 툴바 */}
<ReportDesignerToolbar />
{/* 메인 영역 */}
<div className="flex flex-1 overflow-hidden">
{/* 페이지 목록 패널 */}
<PageListPanel />
{/* 좌측 패널 (템플릿, 컴포넌트) */}
<ReportDesignerLeftPanel />
{/* 중앙 캔버스 */}
<ReportDesignerCanvas />
{/* 우측 패널 (속성) */}
<ReportDesignerRightPanel />
</div>
</div>
</ReportDesignerProvider>
</DndProvider>
);
}

View File

@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ReportListTable } from "@/components/report/ReportListTable";
import { Plus, Search, RotateCcw } from "lucide-react";
import { useReportList } from "@/hooks/useReportList";
export default function ReportManagementPage() {
const router = useRouter();
const [searchText, setSearchText] = useState("");
const { reports, total, page, limit, isLoading, refetch, setPage, handleSearch } = useReportList();
const handleSearchClick = () => {
handleSearch(searchText);
};
const handleReset = () => {
setSearchText("");
handleSearch("");
};
const handleCreateNew = () => {
// 새 리포트는 'new'라는 특수 ID로 디자이너 진입
router.push("/admin/report/designer/new");
};
return (
<div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */}
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<Button onClick={handleCreateNew} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 검색 영역 */}
<Card className="shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="flex gap-2">
<Input
placeholder="리포트명으로 검색..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSearchClick();
}
}}
className="flex-1"
/>
<Button onClick={handleSearchClick} className="gap-2">
<Search className="h-4 w-4" />
</Button>
<Button onClick={handleReset} variant="outline" className="gap-2">
<RotateCcw className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* 리포트 목록 */}
<Card className="shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
📋
<span className="text-muted-foreground text-sm font-normal">( {total})</span>
</span>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ReportListTable
reports={reports}
total={total}
page={page}
limit={limit}
isLoading={isLoading}
onPageChange={setPage}
onRefresh={refetch}
/>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -1,3 +1,6 @@
/* 서명용 손글씨 폰트 - 최상단에 위치해야 함 */
@import url("https://fonts.googleapis.com/css2?family=Allura&family=Dancing+Script:wght@700&family=Great+Vibes&family=Pacifico&family=Satisfy&family=Caveat:wght@700&family=Permanent+Marker&family=Shadows+Into+Light&family=Kalam:wght@700&family=Patrick+Hand&family=Indie+Flower&family=Amatic+SC:wght@700&family=Covered+By+Your+Grace&family=Nanum+Brush+Script&family=Nanum+Pen+Script&family=Gaegu:wght@700&family=Gugi&family=Single+Day&family=Stylish&family=Sunflower:wght@700&family=Dokdo&display=swap");
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@ -76,7 +79,7 @@
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
/* Z-Index 계층 구조 */ /* Z-Index 계층 구조 */
--z-background: 1; --z-background: 1;
--z-layout: 10; --z-layout: 10;

View File

@ -23,6 +23,9 @@ export const metadata: Metadata = {
description: "제품 수명 주기 관리(PLM) 솔루션", description: "제품 수명 주기 관리(PLM) 솔루션",
keywords: ["WACE", "PLM", "Product Lifecycle Management", "WACE", "제품관리"], keywords: ["WACE", "PLM", "Product Lifecycle Management", "WACE", "제품관리"],
authors: [{ name: "WACE" }], authors: [{ name: "WACE" }],
icons: {
icon: "/favicon.ico",
},
}; };
export const viewport: Viewport = { export const viewport: Viewport = {
@ -37,10 +40,6 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="ko" className="h-full"> <html lang="ko" className="h-full">
<head>
<link rel="icon" href="/favicon.ico" />
<meta name="theme-color" content="#0f172a" />
</head>
<body className={`${inter.variable} ${jetbrainsMono.variable} h-full bg-white font-sans antialiased`}> <body className={`${inter.variable} ${jetbrainsMono.variable} h-full bg-white font-sans antialiased`}>
<div id="root" className="h-full"> <div id="root" className="h-full">
<QueryProvider> <QueryProvider>

View File

@ -0,0 +1,228 @@
"use client";
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Loader2 } from "lucide-react";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { CreateReportRequest, ReportTemplate } from "@/types/report";
interface ReportCreateModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateModalProps) {
const [formData, setFormData] = useState<CreateReportRequest>({
reportNameKor: "",
reportNameEng: "",
templateId: undefined,
reportType: "BASIC",
description: "",
});
const [templates, setTemplates] = useState<ReportTemplate[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
const { toast } = useToast();
// 템플릿 목록 불러오기
useEffect(() => {
if (isOpen) {
fetchTemplates();
}
}, [isOpen]);
const fetchTemplates = async () => {
setIsLoadingTemplates(true);
try {
const response = await reportApi.getTemplates();
if (response.success && response.data) {
setTemplates([...response.data.system, ...response.data.custom]);
}
} catch (error: any) {
toast({
title: "오류",
description: "템플릿 목록을 불러오는데 실패했습니다.",
variant: "destructive",
});
} finally {
setIsLoadingTemplates(false);
}
};
const handleSubmit = async () => {
// 유효성 검증
if (!formData.reportNameKor.trim()) {
toast({
title: "입력 오류",
description: "리포트명(한글)을 입력해주세요.",
variant: "destructive",
});
return;
}
if (!formData.reportType) {
toast({
title: "입력 오류",
description: "리포트 타입을 선택해주세요.",
variant: "destructive",
});
return;
}
setIsLoading(true);
try {
const response = await reportApi.createReport(formData);
if (response.success) {
toast({
title: "성공",
description: "리포트가 생성되었습니다.",
});
handleClose();
onSuccess();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트 생성에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
setFormData({
reportNameKor: "",
reportNameEng: "",
templateId: undefined,
reportType: "BASIC",
description: "",
});
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> . .</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* 리포트명 (한글) */}
<div className="space-y-2">
<Label htmlFor="reportNameKor">
() <span className="text-destructive">*</span>
</Label>
<Input
id="reportNameKor"
placeholder="예: 발주서"
value={formData.reportNameKor}
onChange={(e) => setFormData({ ...formData, reportNameKor: e.target.value })}
/>
</div>
{/* 리포트명 (영문) */}
<div className="space-y-2">
<Label htmlFor="reportNameEng"> ()</Label>
<Input
id="reportNameEng"
placeholder="예: Purchase Order"
value={formData.reportNameEng}
onChange={(e) => setFormData({ ...formData, reportNameEng: e.target.value })}
/>
</div>
{/* 템플릿 선택 */}
<div className="space-y-2">
<Label htmlFor="templateId">릿</Label>
<Select
value={formData.templateId || "none"}
onValueChange={(value) => setFormData({ ...formData, templateId: value === "none" ? undefined : value })}
disabled={isLoadingTemplates}
>
<SelectTrigger>
<SelectValue placeholder="템플릿 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">릿 </SelectItem>
{templates.map((template) => (
<SelectItem key={template.template_id} value={template.template_id}>
{template.template_name_kor}
{template.is_system === "Y" && " (시스템)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 리포트 타입 */}
<div className="space-y-2">
<Label htmlFor="reportType">
<span className="text-destructive">*</span>
</Label>
<Select
value={formData.reportType}
onValueChange={(value) => setFormData({ ...formData, reportType: value })}
>
<SelectTrigger>
<SelectValue placeholder="타입 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ORDER"></SelectItem>
<SelectItem value="INVOICE"></SelectItem>
<SelectItem value="STATEMENT"></SelectItem>
<SelectItem value="RECEIPT"></SelectItem>
<SelectItem value="BASIC"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 설명 */}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
placeholder="리포트에 대한 설명을 입력하세요"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
</Button>
<Button onClick={handleSubmit} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"생성"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,250 @@
"use client";
import { useState } from "react";
import { ReportMaster } from "@/types/report";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Pencil, Copy, Trash2, Loader2 } from "lucide-react";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation";
import { format } from "date-fns";
interface ReportListTableProps {
reports: ReportMaster[];
total: number;
page: number;
limit: number;
isLoading: boolean;
onPageChange: (page: number) => void;
onRefresh: () => void;
}
export function ReportListTable({
reports,
total,
page,
limit,
isLoading,
onPageChange,
onRefresh,
}: ReportListTableProps) {
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isCopying, setIsCopying] = useState(false);
const { toast } = useToast();
const router = useRouter();
const totalPages = Math.ceil(total / limit);
// 수정
const handleEdit = (reportId: string) => {
router.push(`/admin/report/designer/${reportId}`);
};
// 복사
const handleCopy = async (reportId: string) => {
setIsCopying(true);
try {
const response = await reportApi.copyReport(reportId);
if (response.success) {
toast({
title: "성공",
description: "리포트가 복사되었습니다.",
});
onRefresh();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트 복사에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsCopying(false);
}
};
// 삭제 확인
const handleDeleteClick = (reportId: string) => {
setDeleteTarget(reportId);
};
// 삭제 실행
const handleDeleteConfirm = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
try {
const response = await reportApi.deleteReport(deleteTarget);
if (response.success) {
toast({
title: "성공",
description: "리포트가 삭제되었습니다.",
});
setDeleteTarget(null);
onRefresh();
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "리포트 삭제에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsDeleting(false);
}
};
// 날짜 포맷
const formatDate = (dateString: string | null) => {
if (!dateString) return "-";
try {
return format(new Date(dateString), "yyyy-MM-dd");
} catch {
return dateString;
}
};
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
);
}
if (reports.length === 0) {
return (
<div className="text-muted-foreground flex h-64 flex-col items-center justify-center">
<p> .</p>
</div>
);
}
return (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[80px]">No</TableHead>
<TableHead></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reports.map((report, index) => {
const rowNumber = (page - 1) * limit + index + 1;
return (
<TableRow key={report.report_id}>
<TableCell className="font-medium">{rowNumber}</TableCell>
<TableCell>
<div>
<div className="font-medium">{report.report_name_kor}</div>
{report.report_name_eng && (
<div className="text-muted-foreground text-sm">{report.report_name_eng}</div>
)}
</div>
</TableCell>
<TableCell>{report.created_by || "-"}</TableCell>
<TableCell>{formatDate(report.updated_at || report.created_at)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(report.report_id)}
className="gap-1"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleCopy(report.report_id)}
disabled={isCopying}
className="gap-1"
>
<Copy className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteClick(report.report_id)}
className="gap-1"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 p-4">
<Button variant="outline" size="sm" onClick={() => onPageChange(page - 1)} disabled={page === 1}>
</Button>
<span className="text-muted-foreground text-sm">
{page} / {totalPages}
</span>
<Button variant="outline" size="sm" onClick={() => onPageChange(page + 1)} disabled={page === totalPages}>
</Button>
</div>
)}
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"삭제"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -0,0 +1,618 @@
"use client";
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;
}
export function CanvasComponent({ component }: CanvasComponentProps) {
const {
components,
selectedComponentId,
selectedComponentIds,
selectComponent,
updateComponent,
getQueryResult,
snapValueToGrid,
calculateAlignmentGuides,
clearAlignmentGuides,
canvasWidth,
canvasHeight,
margins,
} = useReportDesigner();
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [resizeStart, setResizeStart] = useState({ x: 0, y: 0, width: 0, height: 0 });
const componentRef = useRef<HTMLDivElement>(null);
const isSelected = selectedComponentId === component.id;
const isMultiSelected = selectedComponentIds.includes(component.id);
const isLocked = component.locked === true;
const isGrouped = !!component.groupId;
// 드래그 시작
const handleMouseDown = (e: React.MouseEvent) => {
if ((e.target as HTMLElement).classList.contains("resize-handle")) {
return;
}
// 잠긴 컴포넌트는 드래그 불가
if (isLocked) {
e.stopPropagation();
// Ctrl/Cmd 키 감지 (다중 선택)
const isMultiSelect = e.ctrlKey || e.metaKey;
selectComponent(component.id, isMultiSelect);
return;
}
e.stopPropagation();
// Ctrl/Cmd 키 감지 (다중 선택)
const isMultiSelect = e.ctrlKey || e.metaKey;
// 그룹화된 컴포넌트 클릭 시: 같은 그룹의 모든 컴포넌트 선택
if (isGrouped && !isMultiSelect) {
const groupMembers = components.filter((c) => c.groupId === component.groupId);
const groupMemberIds = groupMembers.map((c) => c.id);
// 첫 번째 컴포넌트를 선택하고, 나머지를 다중 선택에 추가
selectComponent(groupMemberIds[0], false);
groupMemberIds.slice(1).forEach((id) => selectComponent(id, true));
} else {
selectComponent(component.id, isMultiSelect);
}
setIsDragging(true);
setDragStart({
x: e.clientX - component.x,
y: e.clientY - component.y,
});
};
// 리사이즈 시작
const handleResizeStart = (e: React.MouseEvent) => {
// 잠긴 컴포넌트는 리사이즈 불가
if (isLocked) {
e.stopPropagation();
return;
}
e.stopPropagation();
setIsResizing(true);
setResizeStart({
x: e.clientX,
y: e.clientY,
width: component.width,
height: component.height,
});
};
// 마우스 이동 핸들러 (전역)
useEffect(() => {
if (!isDragging && !isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (isDragging) {
const newX = Math.max(0, e.clientX - dragStart.x);
const newY = Math.max(0, e.clientY - dragStart.y);
// 여백을 px로 변환 (1mm ≈ 3.7795px)
const marginTopPx = margins.top * 3.7795;
const marginBottomPx = margins.bottom * 3.7795;
const marginLeftPx = margins.left * 3.7795;
const marginRightPx = margins.right * 3.7795;
// 캔버스 경계 체크 (mm를 px로 변환)
const canvasWidthPx = canvasWidth * 3.7795;
const canvasHeightPx = canvasHeight * 3.7795;
// 컴포넌트가 여백 안에 있도록 제한
const minX = marginLeftPx;
const minY = marginTopPx;
const maxX = canvasWidthPx - marginRightPx - component.width;
const maxY = canvasHeightPx - marginBottomPx - component.height;
const boundedX = Math.min(Math.max(minX, newX), maxX);
const boundedY = Math.min(Math.max(minY, newY), maxY);
const snappedX = snapValueToGrid(boundedX);
const snappedY = snapValueToGrid(boundedY);
// 정렬 가이드라인 계산
calculateAlignmentGuides(component.id, snappedX, snappedY, component.width, component.height);
// 이동 거리 계산
const deltaX = snappedX - component.x;
const deltaY = snappedY - component.y;
// 현재 컴포넌트 이동
updateComponent(component.id, {
x: snappedX,
y: snappedY,
});
// 그룹화된 경우: 같은 그룹의 다른 컴포넌트도 함께 이동
if (isGrouped) {
components.forEach((c) => {
if (c.groupId === component.groupId && c.id !== component.id) {
const newGroupX = c.x + deltaX;
const newGroupY = c.y + deltaY;
// 그룹 컴포넌트도 경계 체크
const groupMaxX = canvasWidthPx - c.width;
const groupMaxY = canvasHeightPx - c.height;
updateComponent(c.id, {
x: Math.min(Math.max(0, newGroupX), groupMaxX),
y: Math.min(Math.max(0, newGroupY), groupMaxY),
});
}
});
}
} else if (isResizing) {
const deltaX = e.clientX - resizeStart.x;
const deltaY = e.clientY - resizeStart.y;
const newWidth = Math.max(50, resizeStart.width + deltaX);
const newHeight = Math.max(30, resizeStart.height + deltaY);
// 여백을 px로 변환
const marginRightPx = margins.right * 3.7795;
const marginBottomPx = margins.bottom * 3.7795;
// 캔버스 경계 체크
const canvasWidthPx = canvasWidth * 3.7795;
const canvasHeightPx = canvasHeight * 3.7795;
// 컴포넌트가 여백을 벗어나지 않도록 최대 크기 제한
const maxWidth = canvasWidthPx - marginRightPx - component.x;
const maxHeight = canvasHeightPx - marginBottomPx - component.y;
const boundedWidth = Math.min(newWidth, maxWidth);
const boundedHeight = Math.min(newHeight, maxHeight);
// Grid Snap 적용
updateComponent(component.id, {
width: snapValueToGrid(boundedWidth),
height: snapValueToGrid(boundedHeight),
});
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsResizing(false);
// 가이드라인 초기화
clearAlignmentGuides();
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [
isDragging,
isResizing,
dragStart.x,
dragStart.y,
resizeStart.x,
resizeStart.y,
resizeStart.width,
resizeStart.height,
component.id,
component.x,
component.y,
component.width,
component.height,
component.groupId,
isGrouped,
components,
updateComponent,
snapValueToGrid,
calculateAlignmentGuides,
clearAlignmentGuides,
canvasWidth,
canvasHeight,
]);
// 표시할 값 결정
const getDisplayValue = (): string => {
// 쿼리와 필드가 연결되어 있으면 실제 데이터 조회
if (component.queryId && component.fieldName) {
const queryResult = getQueryResult(component.queryId);
// 실행 결과가 있으면 첫 번째 행의 해당 필드 값 표시
if (queryResult && queryResult.rows.length > 0) {
const firstRow = queryResult.rows[0];
const value = firstRow[component.fieldName];
// 값이 있으면 문자열로 변환하여 반환
if (value !== null && value !== undefined) {
return String(value);
}
}
// 실행 결과가 없거나 값이 없으면 필드명 표시
return `{${component.fieldName}}`;
}
// 기본값이 있으면 기본값 표시
if (component.defaultValue) {
return component.defaultValue;
}
// 둘 다 없으면 타입에 따라 기본 텍스트
return component.type === "text" ? "텍스트 입력" : "레이블 텍스트";
};
// 컴포넌트 타입별 렌더링
const renderContent = () => {
const displayValue = getDisplayValue();
const hasBinding = component.queryId && component.fieldName;
switch (component.type) {
case "text":
return (
<div className="h-full w-full">
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span> </span>
{hasBinding && <span className="text-blue-600"> </span>}
</div>
<div
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
}}
className="w-full"
>
{displayValue}
</div>
</div>
);
case "label":
return (
<div className="h-full w-full">
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span></span>
{hasBinding && <span className="text-blue-600"> </span>}
</div>
<div
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
}}
>
{displayValue}
</div>
</div>
);
case "table":
// 테이블은 쿼리 결과의 모든 행과 필드를 표시
if (component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows.length > 0) {
// tableColumns가 없으면 자동 생성
const columns =
component.tableColumns && component.tableColumns.length > 0
? component.tableColumns
: queryResult.fields.map((field) => ({
field,
header: field,
width: undefined,
align: "left" as const,
}));
return (
<div className="h-full w-full overflow-auto">
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
<span></span>
<span className="text-blue-600"> ({queryResult.rows.length})</span>
</div>
<table
className="w-full border-collapse text-xs"
style={{
borderCollapse: component.showBorder !== false ? "collapse" : "separate",
}}
>
<thead>
<tr
style={{
backgroundColor: component.headerBackgroundColor || "#f3f4f6",
color: component.headerTextColor || "#111827",
}}
>
{columns.map((col) => (
<th
key={col.field}
className={component.showBorder !== false ? "border border-gray-300" : ""}
style={{
padding: "6px 8px",
textAlign: col.align || "left",
width: col.width ? `${col.width}px` : "auto",
fontWeight: "600",
}}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{queryResult.rows.map((row, idx) => (
<tr key={idx}>
{columns.map((col) => (
<td
key={col.field}
className={component.showBorder !== false ? "border border-gray-300" : ""}
style={{
padding: "6px 8px",
textAlign: col.align || "left",
height: component.rowHeight ? `${component.rowHeight}px` : "auto",
}}
>
{String(row[col.field] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
}
// 기본 테이블 (데이터 없을 때)
return (
<div className="h-full w-full">
<div className="mb-1 text-xs text-gray-500"></div>
<div className="flex h-[calc(100%-20px)] items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
</div>
</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>
);
case "signature":
const sigLabelPos = component.labelPosition || "left";
const sigShowLabel = component.showLabel !== false;
const sigLabelText = component.labelText || "서명:";
const sigShowUnderline = component.showUnderline !== false;
return (
<div className="h-full w-full">
<div className="mb-1 text-xs text-gray-500"></div>
<div
className={`flex h-[calc(100%-20px)] gap-2 ${
sigLabelPos === "top"
? "flex-col"
: sigLabelPos === "bottom"
? "flex-col-reverse"
: sigLabelPos === "right"
? "flex-row-reverse"
: "flex-row"
}`}
>
{sigShowLabel && (
<div
className="flex items-center justify-center text-xs font-medium"
style={{
width: sigLabelPos === "left" || sigLabelPos === "right" ? "auto" : "100%",
minWidth: sigLabelPos === "left" || sigLabelPos === "right" ? "40px" : "auto",
}}
>
{sigLabelText}
</div>
)}
<div className="relative flex-1">
{component.imageUrl ? (
<img
src={getFullImageUrl(component.imageUrl)}
alt="서명"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
) : (
<div
className="flex h-full w-full items-center justify-center border-2 border-dashed bg-gray-50 text-xs text-gray-400"
style={{
borderColor: component.borderColor || "#cccccc",
}}
>
</div>
)}
{sigShowUnderline && (
<div
className="absolute right-0 bottom-0 left-0"
style={{
borderBottom: "2px solid #000000",
}}
/>
)}
</div>
</div>
</div>
);
case "stamp":
const stampShowLabel = component.showLabel !== false;
const stampLabelText = component.labelText || "(인)";
const stampPersonName = component.personName || "";
return (
<div className="h-full w-full">
<div className="mb-1 text-xs text-gray-500"></div>
<div className="flex h-[calc(100%-20px)] gap-2">
{stampPersonName && <div className="flex items-center text-xs font-medium">{stampPersonName}</div>}
<div className="relative flex-1">
{component.imageUrl ? (
<img
src={getFullImageUrl(component.imageUrl)}
alt="도장"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
) : (
<div
className="flex h-full w-full items-center justify-center border-2 border-dashed bg-gray-50 text-xs text-gray-400"
style={{
borderColor: component.borderColor || "#cccccc",
borderRadius: "50%",
}}
>
</div>
)}
{stampShowLabel && (
<div
className="absolute inset-0 flex items-center justify-center text-xs font-medium"
style={{
pointerEvents: "none",
}}
>
{stampLabelText}
</div>
)}
</div>
</div>
</div>
);
default:
return <div> </div>;
}
};
return (
<div
ref={componentRef}
className={`absolute p-2 shadow-sm ${isLocked ? "cursor-not-allowed opacity-80" : "cursor-move"} ${
isSelected
? isLocked
? "ring-2 ring-red-500"
: "ring-2 ring-blue-500"
: isMultiSelected
? isLocked
? "ring-2 ring-red-300"
: "ring-2 ring-blue-300"
: ""
}`}
style={{
left: `${component.x}px`,
top: `${component.y}px`,
width: `${component.width}px`,
height: `${component.height}px`,
zIndex: component.zIndex,
backgroundColor: component.backgroundColor,
border: component.borderWidth
? `${component.borderWidth}px solid ${component.borderColor}`
: "1px solid #e5e7eb",
}}
onMouseDown={handleMouseDown}
>
{renderContent()}
{/* 잠금 표시 */}
{isLocked && (
<div className="absolute top-1 right-1 rounded bg-red-500 px-1 py-0.5 text-[10px] text-white">🔒</div>
)}
{/* 그룹화 표시 */}
{isGrouped && !isLocked && (
<div className="absolute top-1 left-1 rounded bg-purple-500 px-1 py-0.5 text-[10px] text-white">👥</div>
)}
{/* 리사이즈 핸들 (선택된 경우만, 잠금 안 된 경우만) */}
{isSelected && !isLocked && (
<div
className="resize-handle absolute right-0 bottom-0 h-3 w-3 cursor-se-resize rounded-full bg-blue-500"
style={{ transform: "translate(50%, 50%)" }}
onMouseDown={handleResizeStart}
/>
)}
</div>
);
}

View File

@ -0,0 +1,52 @@
"use client";
import { useDrag } from "react-dnd";
import { Type, Table, Tag, Image, Minus, PenLine, Stamp as StampIcon } from "lucide-react";
interface ComponentItem {
type: string;
label: string;
icon: React.ReactNode;
}
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" /> },
{ type: "signature", label: "서명란", icon: <PenLine className="h-4 w-4" /> },
{ type: "stamp", label: "도장란", icon: <StampIcon className="h-4 w-4" /> },
];
function DraggableComponentItem({ type, label, icon }: ComponentItem) {
const [{ isDragging }, drag] = useDrag(() => ({
type: "component",
item: { componentType: type },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
}));
return (
<div
ref={drag}
className={`flex cursor-move items-center gap-2 rounded border p-2 text-sm transition-all hover:border-blue-500 hover:bg-blue-50 ${
isDragging ? "opacity-50" : ""
}`}
>
{icon}
<span>{label}</span>
</div>
);
}
export function ComponentPalette() {
return (
<div className="space-y-2">
{COMPONENTS.map((component) => (
<DraggableComponentItem key={component.type} {...component} />
))}
</div>
);
}

View File

@ -0,0 +1,48 @@
"use client";
import { GridConfig } from "@/types/report";
interface GridLayerProps {
gridConfig: GridConfig;
pageWidth: number;
pageHeight: number;
}
export function GridLayer({ gridConfig, pageWidth, pageHeight }: GridLayerProps) {
if (!gridConfig.visible) return null;
const { cellWidth, cellHeight, columns, rows, gridColor, gridOpacity } = gridConfig;
// SVG로 그리드 선 렌더링
return (
<svg className="pointer-events-none absolute inset-0" width={pageWidth} height={pageHeight} style={{ zIndex: 0 }}>
{/* 세로 선 */}
{Array.from({ length: columns + 1 }).map((_, i) => (
<line
key={`v-${i}`}
x1={i * cellWidth}
y1={0}
x2={i * cellWidth}
y2={pageHeight}
stroke={gridColor}
strokeOpacity={gridOpacity}
strokeWidth={1}
/>
))}
{/* 가로 선 */}
{Array.from({ length: rows + 1 }).map((_, i) => (
<line
key={`h-${i}`}
x1={0}
y1={i * cellHeight}
x2={pageWidth}
y2={i * cellHeight}
stroke={gridColor}
strokeOpacity={gridOpacity}
strokeWidth={1}
/>
))}
</svg>
);
}

View File

@ -0,0 +1,138 @@
"use client";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
export function GridSettingsPanel() {
const { gridConfig, updateGridConfig } = useReportDesigner();
return (
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 그리드 표시 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch checked={gridConfig.visible} onCheckedChange={(visible) => updateGridConfig({ visible })} />
</div>
{/* 스냅 활성화 */}
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Switch checked={gridConfig.snapToGrid} onCheckedChange={(snapToGrid) => updateGridConfig({ snapToGrid })} />
</div>
{/* 프리셋 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
onValueChange={(value) => {
const presets: Record<string, { cellWidth: number; cellHeight: number }> = {
fine: { cellWidth: 10, cellHeight: 10 },
medium: { cellWidth: 20, cellHeight: 20 },
coarse: { cellWidth: 50, cellHeight: 50 },
};
updateGridConfig(presets[value]);
}}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="그리드 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fine"> (10x10)</SelectItem>
<SelectItem value="medium"> (20x20)</SelectItem>
<SelectItem value="coarse"> (50x50)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 셀 너비 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<span className="text-xs text-gray-500">{gridConfig.cellWidth}px</span>
</div>
<Slider
value={[gridConfig.cellWidth]}
onValueChange={([value]) => updateGridConfig({ cellWidth: value })}
min={5}
max={100}
step={5}
className="w-full"
/>
</div>
{/* 셀 높이 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<span className="text-xs text-gray-500">{gridConfig.cellHeight}px</span>
</div>
<Slider
value={[gridConfig.cellHeight]}
onValueChange={([value]) => updateGridConfig({ cellHeight: value })}
min={5}
max={100}
step={5}
className="w-full"
/>
</div>
{/* 그리드 투명도 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"></Label>
<span className="text-xs text-gray-500">{Math.round(gridConfig.gridOpacity * 100)}%</span>
</div>
<Slider
value={[gridConfig.gridOpacity * 100]}
onValueChange={([value]) => updateGridConfig({ gridOpacity: value / 100 })}
min={10}
max={100}
step={10}
className="w-full"
/>
</div>
{/* 그리드 색상 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Input
type="color"
value={gridConfig.gridColor}
onChange={(e) => updateGridConfig({ gridColor: e.target.value })}
className="h-8 w-16 cursor-pointer"
/>
<Input
type="text"
value={gridConfig.gridColor}
onChange={(e) => updateGridConfig({ gridColor: e.target.value })}
className="h-8 flex-1 font-mono text-xs"
placeholder="#e5e7eb"
/>
</div>
</div>
{/* 그리드 정보 */}
<div className="rounded border bg-gray-50 p-2 text-xs text-gray-600">
<div className="flex justify-between">
<span>:</span>
<span className="font-mono">{gridConfig.rows}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-mono">{gridConfig.columns}</span>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,209 @@
"use client";
import { useState } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { Plus, Copy, Trash2, GripVertical, Edit2, Check, X } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function PageListPanel() {
const {
layoutConfig,
currentPageId,
addPage,
deletePage,
duplicatePage,
reorderPages,
selectPage,
updatePageSettings,
} = useReportDesigner();
const [editingPageId, setEditingPageId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const handleStartEdit = (pageId: string, currentName: string) => {
setEditingPageId(pageId);
setEditingName(currentName);
};
const handleSaveEdit = () => {
if (editingPageId && editingName.trim()) {
updatePageSettings(editingPageId, { page_name: editingName.trim() });
}
setEditingPageId(null);
setEditingName("");
};
const handleCancelEdit = () => {
setEditingPageId(null);
setEditingName("");
};
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
// 실시간으로 순서 변경하지 않고, drop 시에만 변경
};
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
e.preventDefault();
if (draggedIndex === null) return;
const sourceIndex = draggedIndex;
if (sourceIndex !== targetIndex) {
reorderPages(sourceIndex, targetIndex);
}
setDraggedIndex(null);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
return (
<div className="bg-background flex h-full w-64 flex-col border-r">
{/* 헤더 */}
<div className="flex items-center justify-between border-b p-3">
<h3 className="text-sm font-semibold"> </h3>
<Button size="sm" variant="ghost" onClick={() => addPage()}>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 페이지 목록 */}
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-full p-2">
<div className="space-y-2">
{layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.map((page, index) => (
<div
key={page.page_id}
className={`group relative cursor-pointer rounded-md border p-2 transition-all ${
page.page_id === currentPageId
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-accent/50"
} ${draggedIndex === index ? "opacity-50" : ""}`}
onClick={() => selectPage(page.page_id)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={(e) => handleDrop(e, index)}
>
<div className="flex items-center gap-2">
{/* 드래그 핸들 */}
<div
draggable
onDragStart={(e) => {
e.stopPropagation();
handleDragStart(index);
}}
onDragEnd={handleDragEnd}
className="text-muted-foreground cursor-grab opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-3 w-3" />
</div>
{/* 페이지 정보 */}
<div className="min-w-0 flex-1">
{editingPageId === page.page_id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSaveEdit();
if (e.key === "Escape") handleCancelEdit();
}}
className="h-6 text-xs"
autoFocus
/>
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={handleSaveEdit}>
<Check className="h-3 w-3" />
</Button>
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={handleCancelEdit}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<div className="truncate text-xs font-medium">{page.page_name}</div>
)}
<div className="text-muted-foreground text-[10px]">
{page.width}x{page.height}mm {page.components.length}
</div>
</div>
{/* 액션 메뉴 */}
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100"
>
<span className="sr-only"></span>
<span className="text-sm leading-none"></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleStartEdit(page.page_id, page.page_name);
}}
>
<Edit2 className="mr-2 h-3 w-3" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
duplicatePage(page.page_id);
}}
>
<Copy className="mr-2 h-3 w-3" />
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
deletePage(page.page_id);
}}
disabled={layoutConfig.pages.length <= 1}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-3 w-3" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
{/* 푸터 */}
<div className="border-t p-2">
<Button size="sm" variant="outline" className="w-full" onClick={() => addPage()}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,477 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Plus, Trash2, Play, AlertCircle, Database, Link2 } from "lucide-react";
import { useReportDesigner, ReportQuery } from "@/contexts/ReportDesignerContext";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import type { ExternalConnection } from "@/types/report";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
// SQL 쿼리 안전성 검증 함수 (컴포넌트 외부에 선언)
const validateQuerySafety = (sql: string): { isValid: boolean; errorMessage: string | null } => {
if (!sql || sql.trim() === "") {
return { isValid: false, errorMessage: "쿼리를 입력해주세요." };
}
// 위험한 SQL 명령어 목록
const dangerousKeywords = [
"DELETE",
"DROP",
"TRUNCATE",
"INSERT",
"UPDATE",
"ALTER",
"CREATE",
"REPLACE",
"MERGE",
"GRANT",
"REVOKE",
"EXECUTE",
"EXEC",
"CALL",
];
// SQL을 대문자로 변환하여 검사
const upperSql = sql.toUpperCase().trim();
// 위험한 키워드 검사
for (const keyword of dangerousKeywords) {
// 단어 경계를 고려하여 검사
const regex = new RegExp(`\\b${keyword}\\b`, "i");
if (regex.test(upperSql)) {
return {
isValid: false,
errorMessage: `보안상의 이유로 ${keyword} 명령어는 사용할 수 없습니다. SELECT 쿼리만 허용됩니다.`,
};
}
}
// SELECT 쿼리인지 확인
if (!upperSql.startsWith("SELECT") && !upperSql.startsWith("WITH")) {
return {
isValid: false,
errorMessage: "SELECT 쿼리만 허용됩니다. 데이터 조회 용도로만 사용할 수 있습니다.",
};
}
// 세미콜론으로 구분된 여러 쿼리 방지
const semicolonCount = (sql.match(/;/g) || []).length;
if (semicolonCount > 1 || (semicolonCount === 1 && !sql.trim().endsWith(";"))) {
return {
isValid: false,
errorMessage: "보안상의 이유로 여러 개의 쿼리를 동시에 실행할 수 없습니다.",
};
}
return { isValid: true, errorMessage: null };
};
export function QueryManager() {
const { queries, setQueries, reportId, setQueryResult, getQueryResult } = useReportDesigner();
const [isTestRunning, setIsTestRunning] = useState<Record<string, boolean>>({});
const [parameterValues, setParameterValues] = useState<Record<string, Record<string, string>>>({});
const [parameterTypes, setParameterTypes] = useState<Record<string, Record<string, string>>>({});
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
const [isLoadingConnections, setIsLoadingConnections] = useState(false);
const { toast } = useToast();
// 각 쿼리의 안전성 검증 결과
const getQueryValidation = (query: ReportQuery) => validateQuerySafety(query.sqlQuery);
// 외부 DB 연결 목록 조회
useEffect(() => {
const fetchConnections = async () => {
setIsLoadingConnections(true);
try {
const response = await reportApi.getExternalConnections();
if (response.success && response.data) {
setExternalConnections(response.data);
}
} catch (error) {
console.error("외부 DB 연결 목록 조회 실패:", error);
} finally {
setIsLoadingConnections(false);
}
};
fetchConnections();
}, []);
// 파라미터 감지 ($1, $2 등, 단 작은따옴표 안은 제외)
const detectParameters = (sql: string): string[] => {
// 작은따옴표 안의 내용을 제거
const withoutStrings = sql.replace(/'[^']*'/g, "");
// $숫자 패턴 찾기
const matches = withoutStrings.match(/\$\d+/g);
if (!matches) return [];
// 중복 제거하되 등장 순서 유지
const seen = new Set<string>();
const result: string[] = [];
for (const match of matches) {
if (!seen.has(match)) {
seen.add(match);
result.push(match);
}
}
return result;
};
// 새 쿼리 추가
const handleAddQuery = () => {
const newQuery: ReportQuery = {
id: `query_${Date.now()}`,
name: `쿼리 ${queries.length + 1}`,
type: "MASTER",
sqlQuery: "",
parameters: [],
externalConnectionId: null,
};
setQueries([...queries, newQuery]);
};
// 쿼리 삭제
const handleDeleteQuery = (queryId: string, e: React.MouseEvent) => {
e.stopPropagation();
setQueries(queries.filter((q) => q.id !== queryId));
// 해당 쿼리의 상태 정리
const newParameterValues = { ...parameterValues };
const newParameterTypes = { ...parameterTypes };
const newIsTestRunning = { ...isTestRunning };
delete newParameterValues[queryId];
delete newParameterTypes[queryId];
delete newIsTestRunning[queryId];
setParameterValues(newParameterValues);
setParameterTypes(newParameterTypes);
setIsTestRunning(newIsTestRunning);
};
// 파라미터 값이 모두 입력되었는지 확인
const isAllParametersFilled = (query: ReportQuery): boolean => {
if (!query || query.parameters.length === 0) {
return true;
}
const queryParams = parameterValues[query.id] || {};
return query.parameters.every((param) => {
const value = queryParams[param];
return value !== undefined && value.trim() !== "";
});
};
// 쿼리 업데이트
const handleUpdateQuery = (queryId: string, updates: Partial<ReportQuery>) => {
setQueries(
queries.map((q) => {
if (q.id === queryId) {
const updated = { ...q, ...updates };
// SQL이 변경되면 파라미터 재감지
if (updates.sqlQuery !== undefined) {
updated.parameters = detectParameters(updated.sqlQuery);
}
return updated;
}
return q;
}),
);
};
// 쿼리 테스트 실행
const handleTestQuery = async (query: ReportQuery) => {
// SQL 쿼리 안전성 검증
const validation = validateQuerySafety(query.sqlQuery);
if (!validation.isValid) {
toast({
title: "쿼리 검증 실패",
description: validation.errorMessage || "잘못된 쿼리입니다.",
variant: "destructive",
});
return;
}
setIsTestRunning({ ...isTestRunning, [query.id]: true });
try {
const testReportId = reportId === "new" ? "TEMP_TEST" : reportId;
const sqlQuery = reportId === "new" ? query.sqlQuery : undefined;
const externalConnectionId = (query as any).externalConnectionId || null;
const queryParams = parameterValues[query.id] || {};
const response = await reportApi.executeQuery(
testReportId,
query.id,
queryParams,
sqlQuery,
externalConnectionId,
);
if (response.success && response.data) {
setQueryResult(query.id, response.data.fields, response.data.rows);
toast({
title: "성공",
description: `${response.data.rows.length}건의 데이터가 조회되었습니다.`,
});
}
} catch (error: any) {
toast({
title: "오류",
description: error.response?.data?.message || "쿼리 실행에 실패했습니다.",
variant: "destructive",
});
} finally {
setIsTestRunning({ ...isTestRunning, [query.id]: false });
}
};
return (
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>
<Button size="sm" onClick={handleAddQuery}>
<Plus className="mr-1 h-4 w-4" />
</Button>
</div>
{/* 안내 메시지 */}
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
<strong> </strong> 1 ,
<strong> </strong> .
</AlertDescription>
</Alert>
{/* 아코디언 목록 */}
{queries.length > 0 ? (
<Accordion type="single" collapsible>
{queries.map((query) => {
const testResult = getQueryResult(query.id);
const queryValidation = getQueryValidation(query);
const queryParams = parameterValues[query.id] || {};
const queryParamTypes = parameterTypes[query.id] || {};
return (
<AccordionItem key={query.id} value={query.id} className="border-b border-gray-200">
<AccordionTrigger className="px-0 py-2.5 hover:no-underline">
<div className="flex w-full items-center justify-between pr-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{query.name}</span>
<Badge variant={query.type === "MASTER" ? "default" : "secondary"} className="text-xs">
{query.type}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDeleteQuery(query.id, e)}
className="h-7 w-7 p-0"
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-1 pr-0 pb-3 pl-0">
{/* 쿼리 이름 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
value={query.name}
onChange={(e) => handleUpdateQuery(query.id, { name: e.target.value })}
placeholder="쿼리 이름"
className="h-8"
/>
</div>
{/* 쿼리 타입 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={query.type}
onValueChange={(value: "MASTER" | "DETAIL") => handleUpdateQuery(query.id, { type: value })}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MASTER"> (1)</SelectItem>
<SelectItem value="DETAIL"> ()</SelectItem>
</SelectContent>
</Select>
</div>
{/* DB 연결 선택 */}
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs">
<Link2 className="h-3 w-3" />
DB
</Label>
<Select
value={(query as any).externalConnectionId?.toString() || "internal"}
onValueChange={(value) =>
handleUpdateQuery(query.id, {
externalConnectionId: value === "internal" ? null : parseInt(value),
} as any)
}
disabled={isLoadingConnections}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal">
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
DB (PostgreSQL)
</div>
</SelectItem>
{externalConnections.length > 0 && (
<>
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500"> DB</div>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
{conn.connection_name}
<Badge variant="outline" className="text-xs">
{conn.db_type.toUpperCase()}
</Badge>
</div>
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
{/* SQL 쿼리 */}
<div className="space-y-2">
<Textarea
value={query.sqlQuery}
onChange={(e) => handleUpdateQuery(query.id, { sqlQuery: e.target.value })}
placeholder="SELECT * FROM orders WHERE order_id = $1"
className="min-h-[150px] font-mono text-xs"
/>
</div>
{/* 파라미터 입력 */}
{query.parameters.length > 0 && (
<div className="space-y-3 rounded-md border border-yellow-200 bg-yellow-50 p-3">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-yellow-600" />
<Label className="text-xs font-semibold text-yellow-800"></Label>
</div>
<div className="space-y-2">
{query.parameters.map((param) => {
const paramType = queryParamTypes[param] || "text";
return (
<div key={param} className="flex items-center gap-2">
<Label className="w-12 text-xs font-semibold">{param}</Label>
<Select
value={paramType}
onValueChange={(value) =>
setParameterTypes({
...parameterTypes,
[query.id]: {
...queryParamTypes,
[param]: value,
},
})
}
>
<SelectTrigger className="h-8 w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
</SelectContent>
</Select>
<Input
type={paramType === "number" ? "number" : paramType === "date" ? "date" : "text"}
placeholder="값"
className="h-8 flex-1"
value={queryParams[param] || ""}
onChange={(e) =>
setParameterValues({
...parameterValues,
[query.id]: {
...queryParams,
[param]: e.target.value,
},
})
}
/>
</div>
);
})}
</div>
</div>
)}
{/* SQL 검증 경고 메시지 */}
{!queryValidation.isValid && queryValidation.errorMessage && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">{queryValidation.errorMessage}</AlertDescription>
</Alert>
)}
{/* 테스트 실행 */}
<Button
size="sm"
variant="default"
className="w-full bg-red-500 hover:bg-red-600"
onClick={() => handleTestQuery(query)}
disabled={!queryValidation.isValid || isTestRunning[query.id] || !isAllParametersFilled(query)}
>
<Play className="mr-2 h-4 w-4" />
{isTestRunning[query.id] ? "실행 중..." : "실행"}
</Button>
{/* 결과 필드 */}
{testResult && (
<div className="space-y-2 rounded-md border border-green-200 bg-green-50 p-3">
<Label className="text-xs font-semibold text-green-800"> </Label>
<div className="flex flex-wrap gap-2">
{testResult.fields.map((field) => (
<Badge key={field} variant="default" className="bg-teal-500">
{field}
</Badge>
))}
</div>
<p className="text-xs text-green-700">{testResult.rows.length} .</p>
</div>
)}
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Database className="mb-2 h-12 w-12 text-gray-300" />
<p className="text-sm text-gray-500">
<br />
</p>
</div>
)}
</div>
</ScrollArea>
);
}

View File

@ -0,0 +1,402 @@
"use client";
import { useRef, useEffect } from "react";
import { useDrop } from "react-dnd";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { ComponentConfig } from "@/types/report";
import { CanvasComponent } from "./CanvasComponent";
import { Ruler } from "./Ruler";
import { GridLayer } from "./GridLayer";
import { v4 as uuidv4 } from "uuid";
export function ReportDesignerCanvas() {
const canvasRef = useRef<HTMLDivElement>(null);
const {
currentPageId,
currentPage,
components,
addComponent,
updateComponent,
canvasWidth,
canvasHeight,
margins,
selectComponent,
selectedComponentId,
selectedComponentIds,
removeComponent,
showGrid,
gridSize,
snapValueToGrid,
alignmentGuides,
copyComponents,
pasteComponents,
undo,
redo,
showRuler,
gridConfig,
} = useReportDesigner();
const [{ isOver }, drop] = useDrop(() => ({
accept: "component",
drop: (item: { componentType: string }, monitor) => {
if (!canvasRef.current) return;
const offset = monitor.getClientOffset();
const canvasRect = canvasRef.current.getBoundingClientRect();
if (!offset) return;
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;
} else if (item.componentType === "signature") {
width = 120;
height = 70;
} else if (item.componentType === "stamp") {
width = 70;
height = 70;
}
// 여백을 px로 변환 (1mm ≈ 3.7795px)
const marginTopPx = margins.top * 3.7795;
const marginLeftPx = margins.left * 3.7795;
const marginRightPx = margins.right * 3.7795;
const marginBottomPx = margins.bottom * 3.7795;
// 캔버스 경계 (px)
const canvasWidthPx = canvasWidth * 3.7795;
const canvasHeightPx = canvasHeight * 3.7795;
// 드롭 위치 계산 (여백 내부로 제한)
const rawX = x - 100;
const rawY = y - 25;
const minX = marginLeftPx;
const minY = marginTopPx;
const maxX = canvasWidthPx - marginRightPx - width;
const maxY = canvasHeightPx - marginBottomPx - height;
const boundedX = Math.min(Math.max(minX, rawX), maxX);
const boundedY = Math.min(Math.max(minY, rawY), maxY);
// 새 컴포넌트 생성 (Grid Snap 적용)
const newComponent: ComponentConfig = {
id: `comp_${uuidv4()}`,
type: item.componentType,
x: snapValueToGrid(boundedX),
y: snapValueToGrid(boundedY),
width: snapValueToGrid(width),
height: snapValueToGrid(height),
zIndex: components.length,
fontSize: 13,
fontFamily: "Malgun Gothic",
fontWeight: "normal",
fontColor: "#000000",
backgroundColor: "transparent",
borderWidth: 0,
borderColor: "#cccccc",
borderRadius: 5,
textAlign: "left",
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",
}),
// 서명란 전용
...(item.componentType === "signature" && {
imageUrl: "",
objectFit: "contain" as const,
showLabel: true,
labelText: "서명:",
labelPosition: "left" as const,
showUnderline: true,
borderWidth: 0,
borderColor: "#cccccc",
}),
// 도장란 전용
...(item.componentType === "stamp" && {
imageUrl: "",
objectFit: "contain" as const,
showLabel: true,
labelText: "(인)",
labelPosition: "top" as const,
personName: "",
borderWidth: 0,
borderColor: "#cccccc",
}),
// 테이블 전용
...(item.componentType === "table" && {
queryId: undefined,
tableColumns: [],
headerBackgroundColor: "#f3f4f6",
headerTextColor: "#111827",
showBorder: true,
rowHeight: 32,
}),
};
addComponent(newComponent);
},
collect: (monitor) => ({
isOver: monitor.isOver(),
}),
}));
const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
selectComponent(null);
}
};
// 키보드 단축키 (Delete, Ctrl+C, Ctrl+V, 화살표 이동)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// 입력 필드에서는 단축키 무시
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
return;
}
// 화살표 키: 선택된 컴포넌트 이동
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
e.preventDefault();
// 선택된 컴포넌트가 없으면 무시
if (!selectedComponentId && selectedComponentIds.length === 0) {
return;
}
// 이동 거리 (Shift 키를 누르면 10px, 아니면 1px)
const moveDistance = e.shiftKey ? 10 : 1;
// 이동할 컴포넌트 ID 목록
const idsToMove =
selectedComponentIds.length > 0 ? selectedComponentIds : ([selectedComponentId].filter(Boolean) as string[]);
// 각 컴포넌트 이동 (잠긴 컴포넌트는 제외)
idsToMove.forEach((id) => {
const component = components.find((c) => c.id === id);
if (!component || component.locked) return;
let newX = component.x;
let newY = component.y;
switch (e.key) {
case "ArrowLeft":
newX = Math.max(0, component.x - moveDistance);
break;
case "ArrowRight":
newX = component.x + moveDistance;
break;
case "ArrowUp":
newY = Math.max(0, component.y - moveDistance);
break;
case "ArrowDown":
newY = component.y + moveDistance;
break;
}
updateComponent(id, { x: newX, y: newY });
});
return;
}
// Delete 키: 삭제 (잠긴 컴포넌트는 제외)
if (e.key === "Delete") {
if (selectedComponentIds.length > 0) {
selectedComponentIds.forEach((id) => {
const component = components.find((c) => c.id === id);
if (component && !component.locked) {
removeComponent(id);
}
});
} else if (selectedComponentId) {
const component = components.find((c) => c.id === selectedComponentId);
if (component && !component.locked) {
removeComponent(selectedComponentId);
}
}
}
// Ctrl+C (또는 Cmd+C): 복사
if ((e.ctrlKey || e.metaKey) && e.key === "c") {
e.preventDefault();
copyComponents();
}
// Ctrl+V (또는 Cmd+V): 붙여넣기
if ((e.ctrlKey || e.metaKey) && e.key === "v") {
e.preventDefault();
pasteComponents();
}
// Ctrl+Shift+Z 또는 Ctrl+Y (또는 Cmd+Shift+Z / Cmd+Y): Redo (Undo보다 먼저 체크)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === "z") {
e.preventDefault();
redo();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
e.preventDefault();
redo();
return;
}
// Ctrl+Z (또는 Cmd+Z): Undo
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
undo();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedComponentId,
selectedComponentIds,
components,
removeComponent,
copyComponents,
pasteComponents,
undo,
redo,
]);
// 페이지가 없는 경우
if (!currentPageId || !currentPage) {
return (
<div className="flex flex-1 flex-col items-center justify-center bg-gray-100">
<div className="text-center">
<h3 className="text-lg font-semibold text-gray-700"> </h3>
<p className="mt-2 text-sm text-gray-500"> .</p>
</div>
</div>
);
}
return (
<div className="flex flex-1 flex-col overflow-hidden bg-gray-100">
{/* 작업 영역 제목 */}
<div className="border-b bg-white px-4 py-2 text-center text-sm font-medium text-gray-700">
{currentPage.page_name} ({currentPage.width} x {currentPage.height}mm)
</div>
{/* 캔버스 스크롤 영역 */}
<div className="flex flex-1 items-center justify-center overflow-auto p-8">
{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
<div className="inline-flex flex-col">
{/* 좌상단 코너 + 가로 눈금자 */}
{showRuler && (
<div className="flex">
{/* 좌상단 코너 (20x20) */}
<div className="h-5 w-5 bg-gray-200" />
{/* 가로 눈금자 */}
<Ruler orientation="horizontal" length={canvasWidth} />
</div>
)}
{/* 세로 눈금자 + 캔버스 */}
<div className="flex">
{/* 세로 눈금자 */}
{showRuler && <Ruler orientation="vertical" length={canvasHeight} />}
{/* 캔버스 */}
<div
ref={(node) => {
canvasRef.current = node;
drop(node);
}}
className={`relative bg-white shadow-lg ${isOver ? "ring-2 ring-blue-500" : ""}`}
style={{
width: `${canvasWidth}mm`,
minHeight: `${canvasHeight}mm`,
}}
onClick={handleCanvasClick}
>
{/* 그리드 레이어 */}
<GridLayer
gridConfig={gridConfig}
pageWidth={canvasWidth * 3.7795} // mm to px
pageHeight={canvasHeight * 3.7795}
/>
{/* 페이지 여백 가이드 */}
{currentPage && (
<div
className="pointer-events-none absolute border-2 border-dashed border-blue-300/50"
style={{
top: `${currentPage.margins.top}mm`,
left: `${currentPage.margins.left}mm`,
right: `${currentPage.margins.right}mm`,
bottom: `${currentPage.margins.bottom}mm`,
}}
/>
)}
{/* 정렬 가이드라인 렌더링 */}
{alignmentGuides.vertical.map((x, index) => (
<div
key={`v-${index}`}
className="pointer-events-none absolute top-0 bottom-0"
style={{
left: `${x}px`,
width: "1px",
backgroundColor: "#ef4444",
zIndex: 9999,
}}
/>
))}
{alignmentGuides.horizontal.map((y, index) => (
<div
key={`h-${index}`}
className="pointer-events-none absolute right-0 left-0"
style={{
top: `${y}px`,
height: "1px",
backgroundColor: "#ef4444",
zIndex: 9999,
}}
/>
))}
{/* 컴포넌트 렌더링 */}
{components.map((component) => (
<CanvasComponent key={component.id} component={component} />
))}
{/* 빈 캔버스 안내 */}
{components.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<p className="text-sm"> </p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ComponentPalette } from "./ComponentPalette";
import { TemplatePalette } from "./TemplatePalette";
export function ReportDesignerLeftPanel() {
return (
<div className="w-80 border-r bg-white">
<ScrollArea className="h-full">
<div className="space-y-4 p-4">
{/* 템플릿 */}
<Card className="border-2">
<CardHeader className="pb-3">
<CardTitle className="text-sm"> 릿</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<TemplatePalette />
</CardContent>
</Card>
{/* 컴포넌트 */}
<Card className="border-2">
<CardHeader className="pb-3">
<CardTitle className="text-sm"></CardTitle>
</CardHeader>
<CardContent className="pt-0">
<ComponentPalette />
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,496 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Save,
Eye,
RotateCcw,
ArrowLeft,
Loader2,
BookTemplate,
Grid3x3,
Undo2,
Redo2,
AlignLeft,
AlignRight,
AlignVerticalJustifyStart,
AlignVerticalJustifyEnd,
AlignCenterHorizontal,
AlignCenterVertical,
AlignHorizontalDistributeCenter,
AlignVerticalDistributeCenter,
RectangleHorizontal,
RectangleVertical,
Square,
ChevronDown,
ChevronsDown,
ChevronsUp,
ChevronUp,
Lock,
Unlock,
Ruler as RulerIcon,
Group,
Ungroup,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SaveAsTemplateModal } from "./SaveAsTemplateModal";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { ReportPreviewModal } from "./ReportPreviewModal";
export function ReportDesignerToolbar() {
const router = useRouter();
const {
reportDetail,
saveLayout,
isSaving,
loadLayout,
components,
canvasWidth,
canvasHeight,
queries,
snapToGrid,
setSnapToGrid,
showGrid,
setShowGrid,
undo,
redo,
canUndo,
canRedo,
selectedComponentIds,
alignLeft,
alignRight,
alignTop,
alignBottom,
alignCenterHorizontal,
alignCenterVertical,
distributeHorizontal,
distributeVertical,
makeSameWidth,
makeSameHeight,
makeSameSize,
bringToFront,
sendToBack,
bringForward,
sendBackward,
toggleLock,
lockComponents,
unlockComponents,
showRuler,
setShowRuler,
groupComponents,
ungroupComponents,
} = useReportDesigner();
const [showPreview, setShowPreview] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
const { toast } = useToast();
// 버튼 활성화 조건
const canAlign = selectedComponentIds && selectedComponentIds.length >= 2;
const canDistribute = selectedComponentIds && selectedComponentIds.length >= 3;
const hasSelection = selectedComponentIds && selectedComponentIds.length >= 1;
const canGroup = selectedComponentIds && selectedComponentIds.length >= 2;
// 템플릿 저장 가능 여부: 컴포넌트가 있어야 함
const canSaveAsTemplate = components.length > 0;
// Grid 토글 (Snap과 Grid 표시 함께 제어)
const handleToggleGrid = () => {
const newValue = !snapToGrid;
setSnapToGrid(newValue);
setShowGrid(newValue);
};
const handleSave = async () => {
await saveLayout();
};
const handleSaveAndClose = async () => {
await saveLayout();
router.push("/admin/report");
};
const handleReset = async () => {
if (confirm("현재 변경사항을 모두 취소하고 마지막 저장 상태로 되돌리시겠습니까?")) {
await loadLayout();
}
};
const handleBack = () => {
if (confirm("저장하지 않은 변경사항이 있을 수 있습니다. 목록으로 돌아가시겠습니까?")) {
router.push("/admin/report");
}
};
const handleSaveAsTemplate = async (data: {
templateNameKor: string;
templateNameEng?: string;
description?: string;
}) => {
try {
// 현재 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
const response = await reportApi.createTemplateFromLayout({
templateNameKor: data.templateNameKor,
templateNameEng: data.templateNameEng,
templateType: reportDetail?.report?.report_type || "GENERAL",
description: data.description,
layoutConfig: {
width: canvasWidth,
height: canvasHeight,
orientation: "portrait",
margins: {
top: 10,
bottom: 10,
left: 10,
right: 10,
},
components: components,
},
defaultQueries: queries.map((q, index) => ({
name: q.name,
type: q.type,
sqlQuery: q.sqlQuery,
parameters: q.parameters,
externalConnectionId: q.externalConnectionId || null,
displayOrder: index,
})),
});
if (response.success) {
toast({
title: "성공",
description: "템플릿이 생성되었습니다.",
});
setShowSaveAsTemplate(false);
}
} catch (error: unknown) {
const errorMessage =
error instanceof Error && "response" in error
? (error as { response?: { data?: { message?: string } } }).response?.data?.message ||
"템플릿 생성에 실패했습니다."
: "템플릿 생성에 실패했습니다.";
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
throw error;
}
};
return (
<>
<div className="flex items-center justify-between border-b bg-white px-4 py-3 shadow-sm">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={handleBack} className="gap-2">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="h-6 w-px bg-gray-300" />
<div>
<h2 className="text-lg font-semibold text-gray-900">
{reportDetail?.report.report_name_kor || "리포트 디자이너"}
</h2>
{reportDetail?.report.report_name_eng && (
<p className="text-sm text-gray-500">{reportDetail.report.report_name_eng}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant={snapToGrid && showGrid ? "default" : "outline"}
size="sm"
onClick={handleToggleGrid}
className="gap-2"
title="Grid Snap 및 표시 켜기/끄기"
>
<Grid3x3 className="h-4 w-4" />
{snapToGrid && showGrid ? "Grid ON" : "Grid OFF"}
</Button>
<Button
variant={showRuler ? "default" : "outline"}
size="sm"
onClick={() => setShowRuler(!showRuler)}
className="gap-2"
title="눈금자 표시 켜기/끄기"
>
<RulerIcon className="h-4 w-4" />
{showRuler ? "눈금자 ON" : "눈금자 OFF"}
</Button>
<Button
variant="outline"
size="sm"
onClick={undo}
disabled={!canUndo}
className="gap-2"
title="실행 취소 (Ctrl+Z)"
>
<Undo2 className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={redo}
disabled={!canRedo}
className="gap-2"
title="다시 실행 (Ctrl+Shift+Z)"
>
<Redo2 className="h-4 w-4" />
</Button>
{/* 정렬 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!canAlign}
className="gap-2"
title="정렬 (2개 이상 선택 필요)"
>
<AlignLeft className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={alignLeft}>
<AlignLeft className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={alignRight}>
<AlignRight className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={alignTop}>
<AlignVerticalJustifyStart className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={alignBottom}>
<AlignVerticalJustifyEnd className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={alignCenterHorizontal}>
<AlignCenterHorizontal className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={alignCenterVertical}>
<AlignCenterVertical className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 배치 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!canDistribute}
className="gap-2"
title="균등 배치 (3개 이상 선택 필요)"
>
<AlignHorizontalDistributeCenter className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={distributeHorizontal}>
<AlignHorizontalDistributeCenter className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={distributeVertical}>
<AlignVerticalDistributeCenter className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 크기 조정 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!canAlign}
className="gap-2"
title="크기 조정 (2개 이상 선택 필요)"
>
<Square className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={makeSameWidth}>
<RectangleHorizontal className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={makeSameHeight}>
<RectangleVertical className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={makeSameSize}>
<Square className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 레이어 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!hasSelection}
className="gap-2"
title="레이어 순서 (1개 이상 선택 필요)"
>
<ChevronsUp className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={bringToFront}>
<ChevronsUp className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={bringForward}>
<ChevronUp className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={sendBackward}>
<ChevronDown className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={sendToBack}>
<ChevronsDown className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 잠금 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!hasSelection}
className="gap-2"
title="컴포넌트 잠금/해제 (1개 이상 선택 필요)"
>
<Lock className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={toggleLock}>
<Lock className="mr-2 h-4 w-4" />
(/)
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={lockComponents}>
<Lock className="mr-2 h-4 w-4" />
</DropdownMenuItem>
<DropdownMenuItem onClick={unlockComponents}>
<Unlock className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* 그룹화 드롭다운 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!hasSelection}
className="gap-2"
title="컴포넌트 그룹화/해제"
>
<Group className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={groupComponents} disabled={!canGroup}>
<Group className="mr-2 h-4 w-4" />
(2 )
</DropdownMenuItem>
<DropdownMenuItem onClick={ungroupComponents} disabled={!hasSelection}>
<Ungroup className="mr-2 h-4 w-4" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={handleReset} className="gap-2">
<RotateCcw className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => setShowPreview(true)} className="gap-2">
<Eye className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowSaveAsTemplate(true)}
disabled={!canSaveAsTemplate}
className="gap-2"
title={!canSaveAsTemplate ? "컴포넌트를 추가한 후 템플릿으로 저장할 수 있습니다" : ""}
>
<BookTemplate className="h-4 w-4" />
릿
</Button>
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="h-4 w-4" />
</>
)}
</Button>
<Button size="sm" onClick={handleSaveAndClose} disabled={isSaving} className="gap-2">
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Save className="h-4 w-4" />
</>
)}
</Button>
</div>
</div>
<ReportPreviewModal isOpen={showPreview} onClose={() => setShowPreview(false)} />
<SaveAsTemplateModal
isOpen={showSaveAsTemplate}
onClose={() => setShowSaveAsTemplate(false)}
onSave={handleSaveAsTemplate}
/>
</>
);
}

View File

@ -0,0 +1,918 @@
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Printer, FileDown, FileText } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
// @ts-ignore - docx 라이브러리 타입 이슈
import {
Document,
Packer,
Paragraph,
TextRun,
Table,
TableCell,
TableRow,
WidthType,
ImageRun,
AlignmentType,
VerticalAlign,
convertInchesToTwip,
} from "docx";
import { getFullImageUrl } from "@/lib/api/client";
interface ReportPreviewModalProps {
isOpen: boolean;
onClose: () => void;
}
export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) {
const { layoutConfig, getQueryResult, reportDetail } = useReportDesigner();
const [isExporting, setIsExporting] = useState(false);
const { toast } = useToast();
// 컴포넌트의 실제 표시 값 가져오기
const getComponentValue = (component: any): string => {
if (component.queryId && component.fieldName) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows.length > 0) {
const value = queryResult.rows[0][component.fieldName];
if (value !== null && value !== undefined) {
return String(value);
}
}
return `{${component.fieldName}}`;
}
return component.defaultValue || "텍스트";
};
const handlePrint = () => {
// HTML 생성하여 인쇄
const printHtml = generatePrintHTML();
const printWindow = window.open("", "_blank");
if (!printWindow) return;
printWindow.document.write(printHtml);
printWindow.document.close();
printWindow.print();
};
// 페이지별 컴포넌트 HTML 생성
const generatePageHTML = (
pageComponents: any[],
pageWidth: number,
pageHeight: number,
backgroundColor: string,
): string => {
const componentsHTML = pageComponents
.map((component) => {
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
let content = "";
// Text/Label 컴포넌트
if (component.type === "text" || component.type === "label") {
const displayValue = getComponentValue(component);
content = `<div style="font-size: ${component.fontSize || 13}px; color: ${component.fontColor || "#000000"}; font-weight: ${component.fontWeight || "normal"}; text-align: ${component.textAlign || "left"};">${displayValue}</div>`;
}
// Image 컴포넌트
else if (component.type === "image" && component.imageUrl) {
const imageUrl = component.imageUrl.startsWith("data:")
? component.imageUrl
: getFullImageUrl(component.imageUrl);
content = `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"};" />`;
}
// Divider 컴포넌트
else if (component.type === "divider") {
const width = component.orientation === "horizontal" ? "100%" : `${component.lineWidth || 1}px`;
const height = component.orientation === "vertical" ? "100%" : `${component.lineWidth || 1}px`;
content = `<div style="width: ${width}; height: ${height}; background-color: ${component.lineColor || "#000000"};"></div>`;
}
// Signature 컴포넌트
else if (component.type === "signature") {
const labelPosition = component.labelPosition || "left";
const showLabel = component.showLabel !== false;
const labelText = component.labelText || "서명:";
const imageUrl = component.imageUrl
? component.imageUrl.startsWith("data:")
? component.imageUrl
: getFullImageUrl(component.imageUrl)
: "";
if (labelPosition === "left" || labelPosition === "right") {
content = `
<div style="display: flex; align-items: center; flex-direction: ${labelPosition === "right" ? "row-reverse" : "row"}; gap: 8px; height: 100%;">
${showLabel ? `<div style="font-size: 12px; white-space: nowrap;">${labelText}</div>` : ""}
<div style="flex: 1; position: relative;">
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"};" />` : ""}
${component.showUnderline ? '<div style="position: absolute; bottom: 0; left: 0; right: 0; height: 1px; background-color: #000000;"></div>' : ""}
</div>
</div>`;
} else {
content = `
<div style="display: flex; flex-direction: column; align-items: center; height: 100%;">
${showLabel && labelPosition === "top" ? `<div style="font-size: 12px;">${labelText}</div>` : ""}
<div style="flex: 1; width: 100%; position: relative;">
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"};" />` : ""}
${component.showUnderline ? '<div style="position: absolute; bottom: 0; left: 0; right: 0; height: 1px; background-color: #000000;"></div>' : ""}
</div>
${showLabel && labelPosition === "bottom" ? `<div style="font-size: 12px;">${labelText}</div>` : ""}
</div>`;
}
}
// Stamp 컴포넌트
else if (component.type === "stamp") {
const showLabel = component.showLabel !== false;
const labelText = component.labelText || "(인)";
const personName = component.personName || "";
const imageUrl = component.imageUrl
? component.imageUrl.startsWith("data:")
? component.imageUrl
: getFullImageUrl(component.imageUrl)
: "";
content = `
<div style="display: flex; align-items: center; gap: 8px; height: 100%;">
${personName ? `<div style="font-size: 12px;">${personName}</div>` : ""}
<div style="position: relative; width: ${component.width}px; height: ${component.height}px;">
${imageUrl ? `<img src="${imageUrl}" style="width: 100%; height: 100%; object-fit: ${component.objectFit || "contain"}; border-radius: 50%;" />` : ""}
${showLabel ? `<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 12px; font-weight: bold; color: #dc2626;">${labelText}</div>` : ""}
</div>
</div>`;
}
// Table 컴포넌트
else if (component.type === "table" && queryResult && queryResult.rows.length > 0) {
const columns =
component.tableColumns && component.tableColumns.length > 0
? component.tableColumns
: queryResult.fields.map((field) => ({
field,
header: field,
align: "left" as const,
width: undefined,
}));
const tableRows = queryResult.rows
.map(
(row) => `
<tr>
${columns.map((col: { field: string; align?: string }) => `<td style="border: ${component.showBorder !== false ? "1px solid #d1d5db" : "none"}; padding: 6px 8px; text-align: ${col.align || "left"}; height: ${component.rowHeight || "auto"}px;">${String(row[col.field] ?? "")}</td>`).join("")}
</tr>
`,
)
.join("");
content = `
<table style="width: 100%; border-collapse: ${component.showBorder !== false ? "collapse" : "separate"}; font-size: 12px;">
<thead style="display: table-header-group; break-inside: avoid; break-after: avoid;">
<tr style="background-color: ${component.headerBackgroundColor || "#f3f4f6"}; color: ${component.headerTextColor || "#111827"};">
${columns.map((col: { header: string; align?: string; width?: number }) => `<th style="border: ${component.showBorder !== false ? "1px solid #d1d5db" : "none"}; padding: 6px 8px; text-align: ${col.align || "left"}; width: ${col.width ? `${col.width}px` : "auto"}; font-weight: 600;">${col.header}</th>`).join("")}
</tr>
</thead>
<tbody>
${tableRows}
</tbody>
</table>`;
}
return `
<div style="position: absolute; left: ${component.x}px; top: ${component.y}px; width: ${component.width}px; height: ${component.height}px; background-color: ${component.backgroundColor || "transparent"}; border: ${component.borderWidth ? `${component.borderWidth}px solid ${component.borderColor}` : "none"}; padding: 8px; box-sizing: border-box;">
${content}
</div>`;
})
.join("");
return `
<div style="position: relative; width: ${pageWidth}mm; min-height: ${pageHeight}mm; background-color: ${backgroundColor}; margin: 0 auto;">
${componentsHTML}
</div>`;
};
// 모든 페이지 HTML 생성 (인쇄/PDF용)
const generatePrintHTML = (): string => {
const pagesHTML = layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.map((page) => generatePageHTML(page.components, page.width, page.height, page.background_color))
.join('<div style="page-break-after: always;"></div>');
return `
<html>
<head>
<meta charset="UTF-8">
<title> </title>
<style>
* { box-sizing: border-box; }
@page {
size: A4;
margin: 10mm;
}
@media print {
body { margin: 0; padding: 0; }
.print-page { page-break-after: always; page-break-inside: avoid; }
.print-page:last-child { page-break-after: auto; }
}
body {
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
margin: 0;
padding: 20px;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
</style>
</head>
<body>
${pagesHTML}
<script>
window.onload = function() {
// 이미지 로드 대기 후 인쇄
const images = document.getElementsByTagName('img');
if (images.length === 0) {
setTimeout(() => window.print(), 100);
} else {
let loadedCount = 0;
Array.from(images).forEach(img => {
if (img.complete) {
loadedCount++;
} else {
img.onload = () => {
loadedCount++;
if (loadedCount === images.length) {
setTimeout(() => window.print(), 100);
}
};
}
});
if (loadedCount === images.length) {
setTimeout(() => window.print(), 100);
}
}
}
</script>
</body>
</html>`;
};
// PDF 다운로드 (브라우저 인쇄 기능 이용)
const handleDownloadPDF = () => {
const printHtml = generatePrintHTML();
const printWindow = window.open("", "_blank");
if (!printWindow) return;
printWindow.document.write(printHtml);
printWindow.document.close();
toast({
title: "안내",
description: "인쇄 대화상자에서 'PDF로 저장'을 선택하세요.",
});
};
// Base64를 Uint8Array로 변환
const base64ToUint8Array = (base64: string): Uint8Array => {
const base64Data = base64.split(",")[1] || base64;
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
};
// 컴포넌트를 TableCell로 변환
const createTableCell = (component: any, pageWidth: number, widthPercent?: number): TableCell | null => {
const cellWidth = widthPercent || 100;
if (component.type === "text" || component.type === "label") {
const value = getComponentValue(component);
return new TableCell({
children: [
new Paragraph({
children: [
new TextRun({
text: value,
size: (component.fontSize || 13) * 2,
color: component.fontColor?.replace("#", "") || "000000",
bold: component.fontWeight === "bold",
}),
],
alignment:
component.textAlign === "center"
? AlignmentType.CENTER
: component.textAlign === "right"
? AlignmentType.RIGHT
: AlignmentType.LEFT,
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
verticalAlign: VerticalAlign.CENTER,
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
} else if (component.type === "signature" || component.type === "stamp") {
if (component.imageUrl) {
try {
const imageData = base64ToUint8Array(component.imageUrl);
return new TableCell({
children: [
new Paragraph({
children: [
new ImageRun({
data: imageData,
transformation: {
width: component.width || 150,
height: component.height || 50,
},
}),
],
alignment: AlignmentType.CENTER,
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
verticalAlign: VerticalAlign.CENTER,
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
} catch {
return new TableCell({
children: [
new Paragraph({
children: [
new TextRun({
text: `[${component.type === "signature" ? "서명" : "도장"}]`,
size: 24,
}),
],
}),
],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
}
}
} else if (component.type === "table" && component.queryId) {
const queryResult = getQueryResult(component.queryId);
if (queryResult && queryResult.rows.length > 0) {
const headerCells = queryResult.fields.map(
(field) =>
new TableCell({
children: [new Paragraph({ text: field })],
width: { size: 100 / queryResult.fields.length, type: WidthType.PERCENTAGE },
}),
);
const dataRows = queryResult.rows.map(
(row) =>
new TableRow({
children: queryResult.fields.map(
(field) =>
new TableCell({
children: [new Paragraph({ text: String(row[field] ?? "") })],
}),
),
}),
);
const table = new Table({
rows: [new TableRow({ children: headerCells }), ...dataRows],
width: { size: 100, type: WidthType.PERCENTAGE },
});
return new TableCell({
children: [table],
width: { size: cellWidth, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
},
});
}
}
return null;
};
// WORD 다운로드
const handleDownloadWord = async () => {
setIsExporting(true);
try {
// 페이지별로 섹션 생성
const sections = layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.map((page) => {
// 페이지 크기 설정 (A4 기준)
const pageWidth = convertInchesToTwip(8.27); // A4 width in inches
const pageHeight = convertInchesToTwip(11.69); // A4 height in inches
const marginTop = convertInchesToTwip(page.margins.top / 96); // px to inches (96 DPI)
const marginBottom = convertInchesToTwip(page.margins.bottom / 96);
const marginLeft = convertInchesToTwip(page.margins.left / 96);
const marginRight = convertInchesToTwip(page.margins.right / 96);
// 페이지 내 컴포넌트를 Y좌표 기준으로 정렬
const sortedComponents = [...page.components].sort((a, b) => {
// Y좌표 우선, 같으면 X좌표
if (Math.abs(a.y - b.y) < 5) {
return a.x - b.x;
}
return a.y - b.y;
});
// 컴포넌트들을 행으로 그룹화 (같은 Y 범위 = 같은 행)
const rows: Array<Array<(typeof sortedComponents)[0]>> = [];
const rowTolerance = 20; // Y 좌표 허용 오차
for (const component of sortedComponents) {
const existingRow = rows.find((row) => Math.abs(row[0].y - component.y) < rowTolerance);
if (existingRow) {
existingRow.push(component);
} else {
rows.push([component]);
}
}
// 각 행 내에서 X좌표로 정렬
rows.forEach((row) => row.sort((a, b) => a.x - b.x));
// 페이지 전체를 큰 테이블로 구성 (레이아웃 제어용)
const tableRows: TableRow[] = [];
for (const row of rows) {
if (row.length === 1) {
// 단일 컴포넌트 - 전체 너비 사용
const component = row[0];
const cell = createTableCell(component, pageWidth);
if (cell) {
tableRows.push(
new TableRow({
children: [cell],
height: { value: component.height * 15, rule: 1 }, // 최소 높이 설정
}),
);
}
} else {
// 여러 컴포넌트 - 가로 배치
const cells: TableCell[] = [];
const totalWidth = row.reduce((sum, c) => sum + c.width, 0);
for (const component of row) {
const widthPercent = (component.width / totalWidth) * 100;
const cell = createTableCell(component, pageWidth, widthPercent);
if (cell) {
cells.push(cell);
}
}
if (cells.length > 0) {
const maxHeight = Math.max(...row.map((c) => c.height));
tableRows.push(
new TableRow({
children: cells,
height: { value: maxHeight * 15, rule: 1 },
}),
);
}
}
}
return {
properties: {
page: {
width: pageWidth,
height: pageHeight,
margin: {
top: marginTop,
bottom: marginBottom,
left: marginLeft,
right: marginRight,
},
},
},
children:
tableRows.length > 0
? [
new Table({
rows: tableRows,
width: { size: 100, type: WidthType.PERCENTAGE },
borders: {
top: { style: 0, size: 0, color: "FFFFFF" },
bottom: { style: 0, size: 0, color: "FFFFFF" },
left: { style: 0, size: 0, color: "FFFFFF" },
right: { style: 0, size: 0, color: "FFFFFF" },
insideHorizontal: { style: 0, size: 0, color: "FFFFFF" },
insideVertical: { style: 0, size: 0, color: "FFFFFF" },
},
}),
]
: [new Paragraph({ text: "" })],
};
});
// 문서 생성
const doc = new Document({
sections,
});
// Blob 생성 및 다운로드
const blob = await Packer.toBlob(doc);
const fileName = reportDetail?.report?.report_name_kor || "리포트";
const timestamp = new Date().toISOString().slice(0, 10);
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${fileName}_${timestamp}.docx`;
link.click();
window.URL.revokeObjectURL(url);
toast({
title: "성공",
description: "WORD 파일이 다운로드되었습니다.",
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "WORD 생성에 실패했습니다.";
toast({
title: "오류",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsExporting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
. .
</DialogDescription>
</DialogHeader>
{/* 미리보기 영역 - 모든 페이지 표시 */}
<div className="max-h-[500px] overflow-auto rounded border bg-gray-100 p-4">
<div className="space-y-4">
{layoutConfig.pages
.sort((a, b) => a.page_order - b.page_order)
.map((page) => (
<div key={page.page_id} className="relative">
{/* 페이지 번호 라벨 */}
<div className="mb-2 text-center text-xs text-gray-500">
{page.page_order + 1} - {page.page_name}
</div>
{/* 페이지 컨텐츠 */}
<div
className="relative mx-auto shadow-lg"
style={{
width: `${page.width}mm`,
minHeight: `${page.height}mm`,
backgroundColor: page.background_color,
}}
>
{page.components.map((component) => {
const displayValue = getComponentValue(component);
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
return (
<div
key={component.id}
className="absolute"
style={{
left: `${component.x}px`,
top: `${component.y}px`,
width: `${component.width}px`,
height: `${component.height}px`,
backgroundColor: component.backgroundColor,
border: component.borderWidth
? `${component.borderWidth}px solid ${component.borderColor}`
: "none",
padding: "8px",
}}
>
{component.type === "text" && (
<div
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
}}
>
{displayValue}
</div>
)}
{component.type === "label" && (
<div
style={{
fontSize: `${component.fontSize}px`,
color: component.fontColor,
fontWeight: component.fontWeight,
textAlign: component.textAlign as "left" | "center" | "right",
}}
>
{displayValue}
</div>
)}
{component.type === "table" && queryResult && queryResult.rows.length > 0 ? (
(() => {
// tableColumns가 없으면 자동 생성
const columns =
component.tableColumns && component.tableColumns.length > 0
? component.tableColumns
: queryResult.fields.map((field) => ({
field,
header: field,
align: "left" as const,
width: undefined,
}));
return (
<table
style={{
width: "100%",
borderCollapse: component.showBorder !== false ? "collapse" : "separate",
fontSize: "12px",
}}
>
<thead>
<tr
style={{
backgroundColor: component.headerBackgroundColor || "#f3f4f6",
color: component.headerTextColor || "#111827",
}}
>
{columns.map((col) => (
<th
key={col.field}
style={{
border: component.showBorder !== false ? "1px solid #d1d5db" : "none",
padding: "6px 8px",
textAlign: col.align || "left",
width: col.width ? `${col.width}px` : "auto",
fontWeight: "600",
}}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{queryResult.rows.map((row, idx) => (
<tr key={idx}>
{columns.map((col) => (
<td
key={col.field}
style={{
border: component.showBorder !== false ? "1px solid #d1d5db" : "none",
padding: "6px 8px",
textAlign: col.align || "left",
height: component.rowHeight ? `${component.rowHeight}px` : "auto",
}}
>
{String(row[col.field] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
);
})()
) : 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"}`,
}),
}}
/>
)}
{component.type === "signature" && (
<div
style={{
display: "flex",
gap: "8px",
flexDirection:
component.labelPosition === "top" || component.labelPosition === "bottom"
? "column"
: "row",
...(component.labelPosition === "right" || component.labelPosition === "bottom"
? {
flexDirection:
component.labelPosition === "right" ? "row-reverse" : "column-reverse",
}
: {}),
}}
>
{component.showLabel !== false && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
fontWeight: "500",
minWidth:
component.labelPosition === "left" || component.labelPosition === "right"
? "40px"
: "auto",
}}
>
{component.labelText || "서명:"}
</div>
)}
<div style={{ flex: 1, position: "relative" }}>
{component.imageUrl && (
<img
src={getFullImageUrl(component.imageUrl)}
alt="서명"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
)}
{component.showUnderline !== false && (
<div
style={{
position: "absolute",
bottom: "0",
left: "0",
right: "0",
borderBottom: "2px solid #000000",
}}
/>
)}
</div>
</div>
)}
{component.type === "stamp" && (
<div
style={{
display: "flex",
gap: "8px",
width: "100%",
height: "100%",
}}
>
{component.personName && (
<div
style={{
display: "flex",
alignItems: "center",
fontSize: "12px",
fontWeight: "500",
}}
>
{component.personName}
</div>
)}
<div
style={{
position: "relative",
flex: 1,
}}
>
{component.imageUrl && (
<img
src={getFullImageUrl(component.imageUrl)}
alt="도장"
style={{
width: "100%",
height: "100%",
objectFit: component.objectFit || "contain",
}}
/>
)}
{component.showLabel !== false && (
<div
style={{
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "12px",
fontWeight: "500",
pointerEvents: "none",
}}
>
{component.labelText || "(인)"}
</div>
)}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isExporting}>
</Button>
<Button variant="outline" onClick={handlePrint} disabled={isExporting} className="gap-2">
<Printer className="h-4 w-4" />
</Button>
<Button onClick={handleDownloadPDF} className="gap-2">
<FileDown className="h-4 w-4" />
PDF
</Button>
<Button onClick={handleDownloadWord} disabled={isExporting} variant="secondary" className="gap-2">
<FileText className="h-4 w-4" />
{isExporting ? "생성 중..." : "WORD"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,145 @@
"use client";
import { JSX } from "react";
interface RulerProps {
orientation: "horizontal" | "vertical";
length: number; // mm 단위
offset?: number; // 스크롤 오프셋 (px)
}
export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
// mm를 px로 변환 (1mm = 3.7795px, 96dpi 기준)
const mmToPx = (mm: number) => mm * 3.7795;
const lengthPx = mmToPx(length);
const isHorizontal = orientation === "horizontal";
// 눈금 생성 (10mm 단위 큰 눈금, 5mm 단위 중간 눈금, 1mm 단위 작은 눈금)
const renderTicks = () => {
const ticks: JSX.Element[] = [];
const maxMm = length;
for (let mm = 0; mm <= maxMm; mm++) {
const px = mmToPx(mm);
// 10mm 단위 큰 눈금
if (mm % 10 === 0) {
ticks.push(
<div
key={`major-${mm}`}
className="absolute bg-gray-700"
style={
isHorizontal
? {
left: `${px}px`,
top: "0",
width: "1px",
height: "12px",
}
: {
top: `${px}px`,
left: "0",
height: "1px",
width: "12px",
}
}
/>,
);
// 숫자 표시 (10mm 단위)
if (mm > 0) {
ticks.push(
<div
key={`label-${mm}`}
className="absolute text-[9px] text-gray-600"
style={
isHorizontal
? {
left: `${px + 2}px`,
top: "0px",
}
: {
top: `${px + 2}px`,
left: "0px",
writingMode: "vertical-lr",
}
}
>
{mm}
</div>,
);
}
}
// 5mm 단위 중간 눈금
else if (mm % 5 === 0) {
ticks.push(
<div
key={`medium-${mm}`}
className="absolute bg-gray-500"
style={
isHorizontal
? {
left: `${px}px`,
top: "4px",
width: "1px",
height: "8px",
}
: {
top: `${px}px`,
left: "4px",
height: "1px",
width: "8px",
}
}
/>,
);
}
// 1mm 단위 작은 눈금
else {
ticks.push(
<div
key={`minor-${mm}`}
className="absolute bg-gray-400"
style={
isHorizontal
? {
left: `${px}px`,
top: "8px",
width: "1px",
height: "4px",
}
: {
top: `${px}px`,
left: "8px",
height: "1px",
width: "4px",
}
}
/>,
);
}
}
return ticks;
};
return (
<div
className="relative bg-gray-100 select-none"
style={
isHorizontal
? {
width: `${lengthPx}px`,
height: "20px",
}
: {
width: "20px",
height: `${lengthPx}px`,
}
}
>
{renderTicks()}
</div>
);
}

View File

@ -0,0 +1,152 @@
"use client";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Loader2 } from "lucide-react";
interface SaveAsTemplateModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (data: { templateNameKor: string; templateNameEng?: string; description?: string }) => Promise<void>;
}
export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateModalProps) {
const [formData, setFormData] = useState({
templateNameKor: "",
templateNameEng: "",
description: "",
});
const [isSaving, setIsSaving] = useState(false);
const handleSave = async () => {
if (!formData.templateNameKor.trim()) {
alert("템플릿명을 입력해주세요.");
return;
}
setIsSaving(true);
try {
await onSave({
templateNameKor: formData.templateNameKor,
templateNameEng: formData.templateNameEng || undefined,
description: formData.description || undefined,
});
// 초기화
setFormData({
templateNameKor: "",
templateNameEng: "",
description: "",
});
onClose();
} catch (error) {
console.error("템플릿 저장 실패:", error);
} finally {
setIsSaving(false);
}
};
const handleClose = () => {
if (!isSaving) {
setFormData({
templateNameKor: "",
templateNameEng: "",
description: "",
});
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>릿 </DialogTitle>
<DialogDescription>
릿 .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="templateNameKor">
릿 () <span className="text-red-500">*</span>
</Label>
<Input
id="templateNameKor"
value={formData.templateNameKor}
onChange={(e) =>
setFormData({
...formData,
templateNameKor: e.target.value,
})
}
placeholder="예: 발주서 양식"
disabled={isSaving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="templateNameEng">릿 ()</Label>
<Input
id="templateNameEng"
value={formData.templateNameEng}
onChange={(e) =>
setFormData({
...formData,
templateNameEng: e.target.value,
})
}
placeholder="예: Purchase Order Template"
disabled={isSaving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData({
...formData,
description: e.target.value,
})
}
placeholder="템플릿에 대한 간단한 설명을 입력하세요"
rows={3}
disabled={isSaving}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
"저장"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,254 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Wand2 } from "lucide-react";
interface SignatureGeneratorProps {
onSignatureSelect: (dataUrl: string) => void;
}
// 서명용 손글씨 폰트 목록 (스타일이 확실히 구분되는 폰트들)
const SIGNATURE_FONTS = {
korean: [
{ name: "나눔손글씨 붓", style: "'Nanum Brush Script', cursive", weight: 400 },
{ name: "나눔손글씨 펜", style: "'Nanum Pen Script', cursive", weight: 400 },
{ name: "배달의민족 도현", style: "Dokdo, cursive", weight: 400 },
{ name: "귀여운", style: "Gugi, cursive", weight: 400 },
{ name: "싱글데이", style: "'Single Day', cursive", weight: 400 },
{ name: "스타일리시", style: "Stylish, cursive", weight: 400 },
{ name: "해바라기", style: "Sunflower, sans-serif", weight: 700 },
{ name: "손글씨", style: "Gaegu, cursive", weight: 700 },
],
english: [
{ name: "Allura (우아한)", style: "Allura, cursive", weight: 400 },
{ name: "Dancing Script (춤추는)", style: "'Dancing Script', cursive", weight: 700 },
{ name: "Great Vibes (멋진)", style: "'Great Vibes', cursive", weight: 400 },
{ name: "Pacifico (파도)", style: "Pacifico, cursive", weight: 400 },
{ name: "Satisfy (만족)", style: "Satisfy, cursive", weight: 400 },
{ name: "Caveat (거친)", style: "Caveat, cursive", weight: 700 },
{ name: "Permanent Marker", style: "'Permanent Marker', cursive", weight: 400 },
{ name: "Shadows Into Light", style: "'Shadows Into Light', cursive", weight: 400 },
{ name: "Kalam (볼드)", style: "Kalam, cursive", weight: 700 },
{ name: "Patrick Hand", style: "'Patrick Hand', cursive", weight: 400 },
{ name: "Indie Flower", style: "'Indie Flower', cursive", weight: 400 },
{ name: "Amatic SC", style: "'Amatic SC', cursive", weight: 700 },
{ name: "Covered By Your Grace", style: "'Covered By Your Grace', cursive", weight: 400 },
],
};
export function SignatureGenerator({ onSignatureSelect }: SignatureGeneratorProps) {
const [language, setLanguage] = useState<"korean" | "english">("korean");
const [name, setName] = useState("");
const [generatedSignatures, setGeneratedSignatures] = useState<string[]>([]);
const [isGenerating, setIsGenerating] = useState(false);
const [fontsLoaded, setFontsLoaded] = useState(false);
const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]);
const fonts = SIGNATURE_FONTS[language];
// 컴포넌트 마운트 시 폰트 미리 로드
useEffect(() => {
const loadAllFonts = async () => {
try {
await document.fonts.ready;
// 모든 폰트를 명시적으로 로드
const allFonts = [...SIGNATURE_FONTS.korean, ...SIGNATURE_FONTS.english];
const fontLoadPromises = allFonts.map((font) => document.fonts.load(`${font.weight} 124px ${font.style}`));
await Promise.all(fontLoadPromises);
// 임시 Canvas를 그려서 폰트를 강제로 렌더링 (브라우저가 폰트를 실제로 사용하도록)
const tempCanvas = document.createElement("canvas");
tempCanvas.width = 100;
tempCanvas.height = 100;
const tempCtx = tempCanvas.getContext("2d");
if (tempCtx) {
for (const font of allFonts) {
tempCtx.font = `${font.weight} 124px ${font.style}`;
tempCtx.fillText("테", 0, 50);
tempCtx.fillText("A", 0, 50);
}
}
// 폰트 렌더링 후 대기
await new Promise((resolve) => setTimeout(resolve, 500));
setFontsLoaded(true);
} catch (error) {
console.warn("Font preloading failed:", error);
await new Promise((resolve) => setTimeout(resolve, 1500));
setFontsLoaded(true);
}
};
loadAllFonts();
}, []);
// 서명 생성
const generateSignatures = async () => {
if (!name.trim()) return;
setIsGenerating(true);
// 폰트가 미리 로드될 때까지 대기
if (!fontsLoaded) {
await new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (fontsLoaded) {
clearInterval(checkInterval);
resolve(true);
}
}, 100);
});
}
const newSignatures: string[] = [];
// 동기적으로 하나씩 생성
for (const font of fonts) {
const canvas = document.createElement("canvas");
canvas.width = 500;
canvas.height = 200;
const ctx = canvas.getContext("2d");
if (ctx) {
// 배경 흰색
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 텍스트 스타일
ctx.fillStyle = "#000000";
let fontSize = 124;
ctx.font = `${font.weight} ${fontSize}px ${font.style}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
// 텍스트 너비 측정 및 크기 조정 (캔버스 너비의 90% 이내로)
let textWidth = ctx.measureText(name).width;
const maxWidth = canvas.width * 0.9;
while (textWidth > maxWidth && fontSize > 30) {
fontSize -= 2;
ctx.font = `${font.weight} ${fontSize}px ${font.style}`;
textWidth = ctx.measureText(name).width;
}
// 텍스트 그리기
ctx.fillText(name, canvas.width / 2, canvas.height / 2);
// 데이터 URL로 변환
newSignatures.push(canvas.toDataURL("image/png"));
}
}
setGeneratedSignatures(newSignatures);
setIsGenerating(false);
};
// 서명 선택 (더블클릭)
const handleSignatureDoubleClick = (dataUrl: string) => {
onSignatureSelect(dataUrl);
};
return (
<div className="space-y-3">
{/* 언어 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={language}
onValueChange={(value: "korean" | "english") => {
setLanguage(value);
setName(""); // 언어 변경 시 입력값 초기화
setGeneratedSignatures([]); // 생성된 서명도 초기화
}}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="korean"></SelectItem>
<SelectItem value="english"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 이름 입력 */}
<div className="space-y-2">
<Label className="text-xs">:</Label>
<div className="flex gap-2">
<Input
value={name}
onChange={(e) => {
const input = e.target.value;
// 국문일 때는 한글, 영문일 때는 영문+숫자+공백만 허용
if (language === "korean") {
// 한글만 허용 (자음, 모음, 완성된 글자)
const koreanOnly = input.replace(/[^\u3131-\u3163\uac00-\ud7a3\s]/g, "");
setName(koreanOnly);
} else {
// 영문, 숫자, 공백만 허용
const englishOnly = input.replace(/[^a-zA-Z\s]/g, "");
setName(englishOnly);
}
}}
placeholder={language === "korean" ? "홍길동" : "John Doe"}
maxLength={14}
className="h-8 flex-1"
onKeyDown={(e) => {
if (e.key === "Enter") {
generateSignatures();
}
}}
/>
<Button
type="button"
size="sm"
onClick={generateSignatures}
disabled={!name.trim() || isGenerating || !fontsLoaded}
>
<Wand2 className="mr-1 h-3 w-3" />
{!fontsLoaded ? "폰트 로딩 중..." : isGenerating ? "생성 중..." : "만들기"}
</Button>
</div>
</div>
{/* 생성된 서명 목록 */}
{generatedSignatures.length > 0 && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<p className="text-xs text-gray-500"> </p>
<ScrollArea className="h-[300px] rounded-md border bg-white">
<div className="space-y-2 p-2">
{generatedSignatures.map((signature, index) => (
<Card
key={index}
className="group hover:border-primary cursor-pointer border-2 border-transparent p-3 transition-all"
onDoubleClick={() => handleSignatureDoubleClick(signature)}
>
<div className="flex items-center justify-between">
<img
src={signature}
alt={`서명 ${index + 1}`}
className="h-auto max-h-[45px] w-auto max-w-[280px] object-contain"
/>
<p className="ml-2 text-xs text-gray-400 opacity-0 transition-opacity group-hover:opacity-100">
{fonts[index].name}
</p>
</div>
</Card>
))}
</div>
</ScrollArea>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,135 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Eraser, Pen } from "lucide-react";
interface SignaturePadProps {
onSignatureChange: (dataUrl: string) => void;
initialSignature?: string;
}
export function SignaturePad({ onSignatureChange, initialSignature }: SignaturePadProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [hasDrawn, setHasDrawn] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// 초기 서명이 있으면 로드
if (initialSignature) {
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
setHasDrawn(true);
};
img.src = initialSignature;
} else {
// 캔버스 초기화 (흰색 배경)
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}, [initialSignature]);
const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
setIsDrawing(true);
setHasDrawn(true);
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "#000000";
ctx.lineWidth = 2;
};
const draw = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ctx.lineTo(x, y);
ctx.stroke();
};
const stopDrawing = () => {
if (!isDrawing) return;
setIsDrawing(false);
const canvas = canvasRef.current;
if (!canvas) return;
// 서명 이미지를 Base64 데이터로 변환하여 콜백 호출
const dataUrl = canvas.toDataURL("image/png");
onSignatureChange(dataUrl);
};
const clearSignature = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// 캔버스 클리어 (흰색 배경)
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
setHasDrawn(false);
onSignatureChange("");
};
return (
<div className="space-y-2">
<Card className="overflow-hidden border-2 border-dashed border-gray-300 bg-white p-2">
<canvas
ref={canvasRef}
width={300}
height={150}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
className="cursor-crosshair touch-none"
style={{ width: "100%", height: "auto" }}
/>
</Card>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500">
<Pen className="mr-1 inline h-3 w-3" />
</p>
<Button type="button" variant="outline" size="sm" onClick={clearSignature} disabled={!hasDrawn}>
<Eraser className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,152 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Trash2, Loader2, RefreshCw } from "lucide-react";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { Badge } from "@/components/ui/badge";
interface Template {
template_id: string;
template_name_kor: string;
template_name_eng: string | null;
is_system: string;
}
export function TemplatePalette() {
const { applyTemplate } = useReportDesigner();
const [systemTemplates, setSystemTemplates] = useState<Template[]>([]);
const [customTemplates, setCustomTemplates] = useState<Template[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const { toast } = useToast();
const fetchTemplates = async () => {
setIsLoading(true);
try {
const response = await reportApi.getTemplates();
if (response.success && response.data) {
setSystemTemplates(Array.isArray(response.data.system) ? response.data.system : []);
setCustomTemplates(Array.isArray(response.data.custom) ? response.data.custom : []);
}
} catch (error) {
console.error("템플릿 조회 실패:", error);
toast({
title: "오류",
description: "템플릿 목록을 불러올 수 없습니다.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchTemplates();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleApplyTemplate = async (templateId: string) => {
await applyTemplate(templateId);
};
const handleDeleteTemplate = async (templateId: string, templateName: string) => {
if (!confirm(`"${templateName}" 템플릿을 삭제하시겠습니까?`)) {
return;
}
setDeletingId(templateId);
try {
const response = await reportApi.deleteTemplate(templateId);
if (response.success) {
toast({
title: "성공",
description: "템플릿이 삭제되었습니다.",
});
fetchTemplates();
}
} catch (error: any) {
toast({
title: "오류",
description: error.response?.data?.message || "템플릿 삭제에 실패했습니다.",
variant: "destructive",
});
} finally {
setDeletingId(null);
}
};
return (
<div className="space-y-4">
{/* 시스템 템플릿 (DB에서 조회) */}
{systemTemplates.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-gray-600"> 릿</p>
</div>
{systemTemplates.map((template) => (
<Button
key={template.template_id}
variant="outline"
size="sm"
className="w-full justify-start gap-2 text-sm"
onClick={() => handleApplyTemplate(template.template_id)}
>
<span>{template.template_name_kor}</span>
</Button>
))}
</div>
)}
{/* 사용자 정의 템플릿 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-gray-600"> 릿</p>
<Button variant="ghost" size="sm" onClick={fetchTemplates} disabled={isLoading} className="h-6 w-6 p-0">
<RefreshCw className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
</div>
) : customTemplates.length === 0 ? (
<p className="py-4 text-center text-xs text-gray-400"> 릿 </p>
) : (
customTemplates.map((template) => (
<div key={template.template_id} className="group relative">
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2 pr-8 text-sm"
onClick={() => handleApplyTemplate(template.template_id)}
>
<span>📄</span>
<span className="truncate">{template.template_name_kor}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleDeleteTemplate(template.template_id, template.template_name_kor);
}}
disabled={deletingId === template.template_id}
className="absolute top-1/2 right-1 h-6 w-6 -translate-y-1/2 p-0 opacity-0 transition-opacity group-hover:opacity-100"
>
{deletingId === template.template_id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3 text-red-500" />
)}
</Button>
</div>
))
)}
</div>
</div>
);
}

View File

@ -69,7 +69,7 @@ function Accordion({
return ( return (
<AccordionContext.Provider value={contextValue}> <AccordionContext.Provider value={contextValue}>
<div className={cn("space-y-2", className)} onClick={onClick} {...props}> <div className={cn(className)} onClick={onClick} {...props}>
{children} {children}
</div> </div>
</AccordionContext.Provider> </AccordionContext.Provider>
@ -83,8 +83,33 @@ interface AccordionItemProps {
} }
function AccordionItem({ value, className, children, ...props }: AccordionItemProps) { function AccordionItem({ value, className, children, ...props }: AccordionItemProps) {
const context = React.useContext(AccordionContext);
const handleClick = (e: React.MouseEvent) => {
if (!context?.onValueChange) return;
const target = e.target as HTMLElement;
if (target.closest('button[type="button"]') && !target.closest(".accordion-trigger")) {
return;
}
const isOpen =
context.type === "multiple"
? Array.isArray(context.value) && context.value.includes(value)
: context.value === value;
if (context.type === "multiple") {
const currentValue = Array.isArray(context.value) ? context.value : [];
const newValue = isOpen ? currentValue.filter((v) => v !== value) : [...currentValue, value];
context.onValueChange(newValue);
} else {
const newValue = isOpen && context.collapsible ? "" : value;
context.onValueChange(newValue);
}
};
return ( return (
<div className={cn("rounded-md border", className)} data-value={value} {...props}> <div className={cn("cursor-pointer", className)} data-value={value} onClick={handleClick} {...props}>
{children} {children}
</div> </div>
); );
@ -124,7 +149,7 @@ function AccordionTrigger({ className, children, ...props }: AccordionTriggerPro
return ( return (
<button <button
className={cn( className={cn(
"flex w-full items-center justify-between p-4 text-left font-medium transition-colors hover:bg-gray-50 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none", "accordion-trigger flex w-full cursor-pointer items-center justify-between p-4 text-left font-medium transition-colors focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none",
className, className,
)} )}
onClick={handleClick} onClick={handleClick}
@ -145,6 +170,7 @@ interface AccordionContentProps {
function AccordionContent({ className, children, ...props }: AccordionContentProps) { function AccordionContent({ className, children, ...props }: AccordionContentProps) {
const context = React.useContext(AccordionContext); const context = React.useContext(AccordionContext);
const parent = React.useContext(AccordionItemContext); const parent = React.useContext(AccordionItemContext);
const contentRef = React.useRef<HTMLDivElement>(null);
if (!context || !parent) { if (!context || !parent) {
throw new Error("AccordionContent must be used within AccordionItem"); throw new Error("AccordionContent must be used within AccordionItem");
@ -155,11 +181,18 @@ function AccordionContent({ className, children, ...props }: AccordionContentPro
? Array.isArray(context.value) && context.value.includes(parent.value) ? Array.isArray(context.value) && context.value.includes(parent.value)
: context.value === parent.value; : context.value === parent.value;
if (!isOpen) return null;
return ( return (
<div className={cn("px-4 pb-4 text-sm text-muted-foreground", className)} {...props}> <div
{children} ref={contentRef}
className={cn(
"text-muted-foreground overflow-hidden text-sm transition-all duration-300 ease-in-out",
isOpen ? "max-h-[2000px] opacity-100" : "max-h-0 opacity-0",
className,
)}
onClick={(e) => e.stopPropagation()}
{...props}
>
<div className="cursor-default">{children}</div>
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,65 @@
import { useState, useEffect } from "react";
import { ReportMaster, GetReportsParams } from "@/types/report";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
export function useReportList() {
const [reports, setReports] = useState<ReportMaster[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [limit] = useState(20);
const [isLoading, setIsLoading] = useState(false);
const [searchText, setSearchText] = useState("");
const { toast } = useToast();
const fetchReports = async () => {
setIsLoading(true);
try {
const params: GetReportsParams = {
page,
limit,
searchText,
useYn: "Y",
sortBy: "created_at",
sortOrder: "DESC",
};
const response = await reportApi.getReports(params);
if (response.success && response.data) {
setReports(response.data.items);
setTotal(response.data.total);
}
} catch (error: any) {
console.error("리포트 목록 조회 에러:", error);
toast({
title: "오류",
description: error.message || "리포트 목록을 불러오는데 실패했습니다.",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchReports();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, searchText]);
const handleSearch = (text: string) => {
setSearchText(text);
setPage(1);
};
return {
reports,
total,
page,
limit,
isLoading,
refetch: fetchReports,
setPage,
handleSearch,
};
}

View File

@ -27,6 +27,22 @@ const getApiBaseUrl = (): string => {
export const API_BASE_URL = getApiBaseUrl(); 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 토큰 관리 유틸리티 // JWT 토큰 관리 유틸리티
const TokenManager = { const TokenManager = {
getToken: (): string | null => { getToken: (): string | null => {

View File

@ -0,0 +1,225 @@
import { apiClient } from "./client";
import {
ReportMaster,
ReportDetail,
GetReportsParams,
GetReportsResponse,
CreateReportRequest,
UpdateReportRequest,
SaveLayoutRequest,
GetTemplatesResponse,
CreateTemplateRequest,
ReportLayout,
} from "@/types/report";
const BASE_URL = "/admin/reports";
export const reportApi = {
// 리포트 목록 조회
getReports: async (params: GetReportsParams) => {
const response = await apiClient.get<{
success: boolean;
data: GetReportsResponse;
}>(BASE_URL, { params });
return response.data;
},
// 리포트 상세 조회
getReportById: async (reportId: string) => {
const response = await apiClient.get<{
success: boolean;
data: ReportDetail;
}>(`${BASE_URL}/${reportId}`);
return response.data;
},
// 리포트 생성
createReport: async (data: CreateReportRequest) => {
const response = await apiClient.post<{
success: boolean;
data: { reportId: string };
message: string;
}>(BASE_URL, data);
return response.data;
},
// 리포트 수정
updateReport: async (reportId: string, data: UpdateReportRequest) => {
const response = await apiClient.put<{
success: boolean;
message: string;
}>(`${BASE_URL}/${reportId}`, data);
return response.data;
},
// 리포트 삭제
deleteReport: async (reportId: string) => {
const response = await apiClient.delete<{
success: boolean;
message: string;
}>(`${BASE_URL}/${reportId}`);
return response.data;
},
// 리포트 복사
copyReport: async (reportId: string) => {
const response = await apiClient.post<{
success: boolean;
data: { reportId: string };
message: string;
}>(`${BASE_URL}/${reportId}/copy`);
return response.data;
},
// 레이아웃 조회
getLayout: async (reportId: string) => {
const response = await apiClient.get<{
success: boolean;
data: ReportLayout;
}>(`${BASE_URL}/${reportId}/layout`);
return response.data;
},
// 레이아웃 저장
saveLayout: async (reportId: string, data: SaveLayoutRequest) => {
const response = await apiClient.put<{
success: boolean;
message: string;
}>(`${BASE_URL}/${reportId}/layout`, data);
return response.data;
},
// 템플릿 목록 조회
getTemplates: async () => {
const response = await apiClient.get<{
success: boolean;
data: GetTemplatesResponse;
}>(`${BASE_URL}/templates`);
return response.data;
},
// 템플릿 생성
createTemplate: async (data: CreateTemplateRequest) => {
const response = await apiClient.post<{
success: boolean;
data: { templateId: string };
message: string;
}>(`${BASE_URL}/templates`, data);
return response.data;
},
// 템플릿 삭제
deleteTemplate: async (templateId: string) => {
const response = await apiClient.delete<{
success: boolean;
message: string;
}>(`${BASE_URL}/templates/${templateId}`);
return response.data;
},
// 쿼리 실행
executeQuery: async (
reportId: string,
queryId: string,
parameters: Record<string, any>,
sqlQuery?: string,
externalConnectionId?: number | null,
) => {
const response = await apiClient.post<{
success: boolean;
data: {
fields: string[];
rows: any[];
};
}>(`${BASE_URL}/${reportId}/queries/${queryId}/execute`, {
parameters,
sqlQuery,
externalConnectionId,
});
return response.data;
},
// 외부 DB 연결 목록 조회
getExternalConnections: async () => {
const response = await apiClient.get<{
success: boolean;
data: any[];
}>(`${BASE_URL}/external-connections`);
return response.data;
},
// 현재 리포트를 템플릿으로 저장
saveAsTemplate: async (
reportId: string,
data: {
templateNameKor: string;
templateNameEng?: string;
description?: string;
},
) => {
const response = await apiClient.post<{
success: boolean;
data: { templateId: string };
message: string;
}>(`${BASE_URL}/${reportId}/save-as-template`, data);
return response.data;
},
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
createTemplateFromLayout: async (data: {
templateNameKor: string;
templateNameEng?: string;
templateType?: string;
description?: string;
layoutConfig: {
width: number;
height: number;
orientation: string;
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
components: any[];
};
defaultQueries?: Array<{
name: string;
type: "MASTER" | "DETAIL";
sqlQuery: string;
parameters: string[];
externalConnectionId?: number | null;
displayOrder?: number;
}>;
}) => {
const response = await apiClient.post<{
success: boolean;
data: { templateId: string };
message: string;
}>(`${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

@ -1,389 +1,155 @@
import { Position, Size } from "@/types/screen"; import type { ComponentConfig, GridConfig } from "@/types/report";
import { GridSettings } from "@/types/screen-management";
export interface GridInfo { /**
columnWidth: number; *
totalWidth: number; */
totalHeight: number; export function pixelToGrid(pixel: number, cellSize: number): number {
return Math.round(pixel / cellSize);
} }
/** /**
* *
*/ */
export function calculateGridInfo( export function gridToPixel(grid: number, cellSize: number): number {
containerWidth: number, return grid * cellSize;
containerHeight: number, }
gridSettings: GridSettings,
): GridInfo {
const { columns, gap, padding } = gridSettings;
// 사용 가능한 너비 계산 (패딩 제외) /**
const availableWidth = containerWidth - padding * 2; * /
*/
export function snapComponentToGrid(component: ComponentConfig, gridConfig: GridConfig): ComponentConfig {
if (!gridConfig.snapToGrid) {
return component;
}
// 격자 간격을 고려한 컬럼 너비 계산 // 픽셀 좌표를 그리드 좌표로 변환
const totalGaps = (columns - 1) * gap; const gridX = pixelToGrid(component.x, gridConfig.cellWidth);
const columnWidth = (availableWidth - totalGaps) / columns; const gridY = pixelToGrid(component.y, gridConfig.cellHeight);
const gridWidth = Math.max(1, pixelToGrid(component.width, gridConfig.cellWidth));
const gridHeight = Math.max(1, pixelToGrid(component.height, gridConfig.cellHeight));
// 그리드 좌표를 다시 픽셀로 변환
return { return {
columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시 ...component,
totalWidth: containerWidth, gridX,
totalHeight: containerHeight, gridY,
gridWidth,
gridHeight,
x: gridToPixel(gridX, gridConfig.cellWidth),
y: gridToPixel(gridY, gridConfig.cellHeight),
width: gridToPixel(gridWidth, gridConfig.cellWidth),
height: gridToPixel(gridHeight, gridConfig.cellHeight),
}; };
} }
/** /**
* *
*
*/ */
export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings: GridSettings): Position { export function detectGridCollision(
if (!gridSettings.snapToGrid) { component: ComponentConfig,
return position; otherComponents: ComponentConfig[],
} gridConfig: GridConfig,
const { columnWidth } = gridInfo;
const { gap, padding } = gridSettings;
// 격자 셀 크기 (컬럼 너비 + 간격을 하나의 격자 단위로 계산)
const cellWidth = columnWidth + gap;
const cellHeight = Math.max(40, gap * 2); // 행 높이를 더 크게 설정
// 패딩을 제외한 상대 위치
const relativeX = position.x - padding;
const relativeY = position.y - padding;
// 격자 기준으로 위치 계산 (가장 가까운 격자점으로 스냅)
const gridX = Math.round(relativeX / cellWidth);
const gridY = Math.round(relativeY / cellHeight);
// 실제 픽셀 위치로 변환
const snappedX = Math.max(padding, padding + gridX * cellWidth);
const snappedY = Math.max(padding, padding + gridY * cellHeight);
return {
x: snappedX,
y: snappedY,
z: position.z,
};
}
/**
*
*/
export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: GridSettings): Size {
if (!gridSettings.snapToGrid) {
return size;
}
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
// 격자 단위로 너비 계산
// 컴포넌트가 차지하는 컬럼 수를 올바르게 계산
let gridColumns = 1;
// 현재 너비에서 가장 가까운 격자 컬럼 수 찾기
for (let cols = 1; cols <= gridSettings.columns; cols++) {
const targetWidth = cols * columnWidth + (cols - 1) * gap;
if (size.width <= targetWidth + (columnWidth + gap) / 2) {
gridColumns = cols;
break;
}
gridColumns = cols;
}
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
// 높이는 동적 행 높이 단위로 스냅
const rowHeight = Math.max(20, gap);
const snappedHeight = Math.max(40, Math.round(size.height / rowHeight) * rowHeight);
console.log(
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
);
return {
width: Math.max(columnWidth, snappedWidth),
height: snappedHeight,
};
}
/**
*
*/
export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
return columns * columnWidth + (columns - 1) * gap;
}
/**
* gridColumns
*/
export function updateSizeFromGridColumns(
component: { gridColumns?: number; size: Size },
gridInfo: GridInfo,
gridSettings: GridSettings,
): Size {
if (!component.gridColumns || component.gridColumns < 1) {
return component.size;
}
const newWidth = calculateWidthFromColumns(component.gridColumns, gridInfo, gridSettings);
return {
width: newWidth,
height: component.size.height, // 높이는 유지
};
}
/**
* gridColumns를
*/
export function adjustGridColumnsFromSize(
component: { size: Size },
gridInfo: GridInfo,
gridSettings: GridSettings,
): number {
const columns = calculateColumnsFromWidth(component.size.width, gridInfo, gridSettings);
return Math.min(Math.max(1, columns), gridSettings.columns); // 1-12 범위로 제한
}
/**
*
*/
export function calculateColumnsFromWidth(width: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
return Math.max(1, Math.round((width + gap) / (columnWidth + gap)));
}
/**
*
*/
export function generateGridLines(
containerWidth: number,
containerHeight: number,
gridSettings: GridSettings,
): {
verticalLines: number[];
horizontalLines: number[];
} {
const { columns, gap, padding } = gridSettings;
const gridInfo = calculateGridInfo(containerWidth, containerHeight, gridSettings);
const { columnWidth } = gridInfo;
// 격자 셀 크기 (스냅 로직과 동일하게)
const cellWidth = columnWidth + gap;
const cellHeight = Math.max(40, gap * 2);
// 세로 격자선
const verticalLines: number[] = [];
for (let i = 0; i <= columns; i++) {
const x = padding + i * cellWidth;
if (x <= containerWidth) {
verticalLines.push(x);
}
}
// 가로 격자선
const horizontalLines: number[] = [];
for (let y = padding; y < containerHeight; y += cellHeight) {
horizontalLines.push(y);
}
return {
verticalLines,
horizontalLines,
};
}
/**
*
*/
export function isOnGridBoundary(
position: Position,
size: Size,
gridInfo: GridInfo,
gridSettings: GridSettings,
tolerance: number = 5,
): boolean { ): boolean {
const snappedPos = snapToGrid(position, gridInfo, gridSettings); const comp1GridX = component.gridX ?? pixelToGrid(component.x, gridConfig.cellWidth);
const snappedSize = snapSizeToGrid(size, gridInfo, gridSettings); const comp1GridY = component.gridY ?? pixelToGrid(component.y, gridConfig.cellHeight);
const comp1GridWidth = component.gridWidth ?? pixelToGrid(component.width, gridConfig.cellWidth);
const comp1GridHeight = component.gridHeight ?? pixelToGrid(component.height, gridConfig.cellHeight);
const positionMatch = for (const other of otherComponents) {
Math.abs(position.x - snappedPos.x) <= tolerance && Math.abs(position.y - snappedPos.y) <= tolerance; if (other.id === component.id) continue;
const sizeMatch = const comp2GridX = other.gridX ?? pixelToGrid(other.x, gridConfig.cellWidth);
Math.abs(size.width - snappedSize.width) <= tolerance && Math.abs(size.height - snappedSize.height) <= tolerance; const comp2GridY = other.gridY ?? pixelToGrid(other.y, gridConfig.cellHeight);
const comp2GridWidth = other.gridWidth ?? pixelToGrid(other.width, gridConfig.cellWidth);
const comp2GridHeight = other.gridHeight ?? pixelToGrid(other.height, gridConfig.cellHeight);
return positionMatch && sizeMatch; // AABB (Axis-Aligned Bounding Box) 충돌 감지
} const xOverlap = comp1GridX < comp2GridX + comp2GridWidth && comp1GridX + comp1GridWidth > comp2GridX;
const yOverlap = comp1GridY < comp2GridY + comp2GridHeight && comp1GridY + comp1GridHeight > comp2GridY;
/** if (xOverlap && yOverlap) {
* return true;
*/ }
export function alignGroupChildrenToGrid(
children: any[],
groupPosition: Position,
gridInfo: GridInfo,
gridSettings: GridSettings,
): any[] {
if (!gridSettings.snapToGrid || children.length === 0) return children;
console.log("🔧 alignGroupChildrenToGrid 시작:", {
childrenCount: children.length,
groupPosition,
gridInfo,
gridSettings,
});
return children.map((child, index) => {
console.log(`📐 자식 ${index + 1} 처리 중:`, {
childId: child.id,
originalPosition: child.position,
originalSize: child.size,
});
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
// 그룹 내부 패딩 고려한 격자 정렬
const padding = 16;
const effectiveX = child.position.x - padding;
const columnIndex = Math.round(effectiveX / (columnWidth + gap));
const snappedX = padding + columnIndex * (columnWidth + gap);
// Y 좌표는 동적 행 높이 단위로 스냅
const rowHeight = Math.max(20, gap);
const effectiveY = child.position.y - padding;
const rowIndex = Math.round(effectiveY / rowHeight);
const snappedY = padding + rowIndex * rowHeight;
// 크기는 외부 격자와 동일하게 스냅 (columnWidth + gap 사용)
const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기
const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth));
const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기
const snappedHeight = Math.max(40, Math.round(child.size.height / rowHeight) * rowHeight);
const snappedChild = {
...child,
position: {
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: Math.max(padding, snappedY),
z: child.position.z || 1,
},
size: {
width: snappedWidth,
height: snappedHeight,
},
};
console.log(`✅ 자식 ${index + 1} 격자 정렬 완료:`, {
childId: child.id,
calculation: {
effectiveX,
effectiveY,
columnIndex,
rowIndex,
widthInColumns,
originalX: child.position.x,
snappedX: snappedChild.position.x,
padding,
},
snappedPosition: snappedChild.position,
snappedSize: snappedChild.size,
deltaX: snappedChild.position.x - child.position.x,
deltaY: snappedChild.position.y - child.position.y,
});
return snappedChild;
});
}
/**
*
*/
export function calculateOptimalGroupSize(
children: Array<{ position: Position; size: Size }>,
gridInfo: GridInfo,
gridSettings: GridSettings,
): Size {
if (children.length === 0) {
return { width: gridInfo.columnWidth * 2, height: 40 * 2 };
} }
console.log("📏 calculateOptimalGroupSize 시작:", { return false;
childrenCount: children.length,
children: children.map((c) => ({ pos: c.position, size: c.size })),
});
// 모든 자식 컴포넌트를 포함하는 최소 경계 계산
const bounds = children.reduce(
(acc, child) => ({
minX: Math.min(acc.minX, child.position.x),
minY: Math.min(acc.minY, child.position.y),
maxX: Math.max(acc.maxX, child.position.x + child.size.width),
maxY: Math.max(acc.maxY, child.position.y + child.size.height),
}),
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
console.log("📐 경계 계산:", bounds);
const contentWidth = bounds.maxX - bounds.minX;
const contentHeight = bounds.maxY - bounds.minY;
// 그룹은 격자 스냅 없이 컨텐츠에 맞는 자연스러운 크기
const padding = 16; // 그룹 내부 여백
const groupSize = {
width: contentWidth + padding * 2,
height: contentHeight + padding * 2,
};
console.log("✅ 자연스러운 그룹 크기:", {
contentSize: { width: contentWidth, height: contentHeight },
withPadding: groupSize,
strategy: "그룹은 격자 스냅 없이, 내부 컴포넌트만 격자에 맞춤",
});
return groupSize;
} }
/** /**
* * /
*/ */
export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] { export function calculateGridDimensions(
if (!gridSettings.snapToGrid || children.length === 0) return children; pageWidth: number,
pageHeight: number,
console.log("🔄 normalizeGroupChildPositions 시작:", { cellWidth: number,
childrenCount: children.length, cellHeight: number,
originalPositions: children.map((c) => ({ id: c.id, pos: c.position })), ): { rows: number; columns: number } {
}); return {
columns: Math.floor(pageWidth / cellWidth),
// 모든 자식의 최소 위치 찾기 rows: Math.floor(pageHeight / cellHeight),
const minX = Math.min(...children.map((child) => child.position.x)); };
const minY = Math.min(...children.map((child) => child.position.y)); }
console.log("📍 최소 위치:", { minX, minY }); /**
*
// 그룹 내에서 시작점을 패딩만큼 떨어뜨림 (자연스러운 여백) */
const padding = 16; export function createDefaultGridConfig(pageWidth: number, pageHeight: number): GridConfig {
const startX = padding; const cellWidth = 20;
const startY = padding; const cellHeight = 20;
const { rows, columns } = calculateGridDimensions(pageWidth, pageHeight, cellWidth, cellHeight);
const normalizedChildren = children.map((child) => ({
...child, return {
position: { cellWidth,
x: child.position.x - minX + startX, cellHeight,
y: child.position.y - minY + startY, rows,
z: child.position.z || 1, columns,
}, visible: true,
})); snapToGrid: true,
gridColor: "#e5e7eb",
console.log("✅ 정규화 완료:", { gridOpacity: 0.5,
normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })), };
}); }
return normalizedChildren; /**
*
*/
export function isWithinPageBounds(
component: ComponentConfig,
pageWidth: number,
pageHeight: number,
margins: { top: number; bottom: number; left: number; right: number },
): boolean {
const minX = margins.left;
const minY = margins.top;
const maxX = pageWidth - margins.right;
const maxY = pageHeight - margins.bottom;
return (
component.x >= minX &&
component.y >= minY &&
component.x + component.width <= maxX &&
component.y + component.height <= maxY
);
}
/**
*
*/
export function constrainToPageBounds(
component: ComponentConfig,
pageWidth: number,
pageHeight: number,
margins: { top: number; bottom: number; left: number; right: number },
): ComponentConfig {
const minX = margins.left;
const minY = margins.top;
const maxX = pageWidth - margins.right - component.width;
const maxY = pageHeight - margins.bottom - component.height;
return {
...component,
x: Math.max(minX, Math.min(maxX, component.x)),
y: Math.max(minY, Math.min(maxY, component.y)),
};
} }

View File

@ -39,21 +39,26 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"docx": "^9.5.1",
"docx-preview": "^0.3.6", "docx-preview": "^0.3.6",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"next": "15.4.4", "next": "15.4.4",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.9.0", "react-day-picker": "^9.9.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-resizable-panels": "^3.0.6",
"react-window": "^2.1.0", "react-window": "^2.1.0",
"reactflow": "^11.10.4", "reactflow": "^11.10.4",
"recharts": "^3.2.1", "recharts": "^3.2.1",
"sheetjs-style": "^0.15.8", "sheetjs-style": "^0.15.8",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"uuid": "^13.0.0",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"zod": "^4.1.5" "zod": "^4.1.5"
}, },
@ -64,6 +69,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19.1.13", "@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@types/uuid": "^10.0.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.4", "eslint-config-next": "15.4.4",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@ -89,6 +95,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@date-fns/tz": { "node_modules/@date-fns/tz": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
@ -2409,6 +2424,24 @@
} }
} }
}, },
"node_modules/@react-dnd/asap": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
"integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A==",
"license": "MIT"
},
"node_modules/@react-dnd/invariant": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
"integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw==",
"license": "MIT"
},
"node_modules/@react-dnd/shallowequal": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
"integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==",
"license": "MIT"
},
"node_modules/@rtsao/scc": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -3103,7 +3136,7 @@
"version": "20.19.17", "version": "20.19.17",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@ -3143,6 +3176,13 @@
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.44.1", "version": "8.44.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz",
@ -4870,6 +4910,17 @@
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/dnd-core": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
"integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
"license": "MIT",
"dependencies": {
"@react-dnd/asap": "^5.0.1",
"@react-dnd/invariant": "^4.0.1",
"redux": "^4.2.0"
}
},
"node_modules/doctrine": { "node_modules/doctrine": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@ -4883,6 +4934,23 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/docx": {
"version": "9.5.1",
"resolved": "https://registry.npmjs.org/docx/-/docx-9.5.1.tgz",
"integrity": "sha512-ABDI7JEirFD2+bHhOBlsGZxaG1UgZb2M/QMKhLSDGgVNhxDesTCDcP+qoDnDGjZ4EOXTRfUjUgwHVuZ6VSTfWQ==",
"license": "MIT",
"dependencies": {
"@types/node": "^24.0.1",
"hash.js": "^1.1.7",
"jszip": "^3.10.1",
"nanoid": "^5.1.3",
"xml": "^1.0.1",
"xml-js": "^1.6.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/docx-preview": { "node_modules/docx-preview": {
"version": "0.3.6", "version": "0.3.6",
"resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.6.tgz", "resolved": "https://registry.npmjs.org/docx-preview/-/docx-preview-0.3.6.tgz",
@ -4892,6 +4960,39 @@
"jszip": ">=3.0.0" "jszip": ">=3.0.0"
} }
}, },
"node_modules/docx/node_modules/@types/node": {
"version": "24.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.1.tgz",
"integrity": "sha512-ljvjjs3DNXummeIaooB4cLBKg2U6SPI6Hjra/9rRIy7CpM0HpLtG9HptkMKAb4HYWy5S7HUvJEuWgr/y0U8SHw==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.13.0"
}
},
"node_modules/docx/node_modules/nanoid": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^18 || >=20"
}
},
"node_modules/docx/node_modules/undici-types": {
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz",
"integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==",
"license": "MIT"
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@ -5688,7 +5789,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": { "node_modules/fast-diff": {
@ -6169,6 +6269,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"minimalistic-assert": "^1.0.1"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@ -6181,6 +6291,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -7234,6 +7353,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -8055,6 +8180,45 @@
"react": ">=16.8.0" "react": ">=16.8.0"
} }
}, },
"node_modules/react-dnd": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
"integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
"license": "MIT",
"dependencies": {
"@react-dnd/invariant": "^4.0.1",
"@react-dnd/shallowequal": "^4.0.1",
"dnd-core": "^16.0.1",
"fast-deep-equal": "^3.1.3",
"hoist-non-react-statics": "^3.3.2"
},
"peerDependencies": {
"@types/hoist-non-react-statics": ">= 3.3.1",
"@types/node": ">= 12",
"@types/react": ">= 16",
"react": ">= 16.14"
},
"peerDependenciesMeta": {
"@types/hoist-non-react-statics": {
"optional": true
},
"@types/node": {
"optional": true
},
"@types/react": {
"optional": true
}
}
},
"node_modules/react-dnd-html5-backend": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
"license": "MIT",
"dependencies": {
"dnd-core": "^16.0.1"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@ -8176,6 +8340,16 @@
} }
} }
}, },
"node_modules/react-resizable-panels": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz",
"integrity": "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==",
"license": "MIT",
"peerDependencies": {
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/react-style-singleton": { "node_modules/react-style-singleton": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@ -8303,6 +8477,15 @@
"redux": "^5.0.0" "redux": "^5.0.0"
} }
}, },
"node_modules/redux": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
},
"node_modules/reflect.getprototypeof": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -8490,6 +8673,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sax": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
"license": "ISC"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.26.0", "version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
@ -9297,7 +9486,7 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
@ -9425,6 +9614,19 @@
"d3-timer": "^3.0.1" "d3-timer": "^3.0.1"
} }
}, },
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -9609,6 +9811,24 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
"license": "MIT"
},
"node_modules/xml-js": {
"version": "1.6.11",
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
"license": "MIT",
"dependencies": {
"sax": "^1.2.4"
},
"bin": {
"xml-js": "bin/cli.js"
}
},
"node_modules/xmlbuilder": { "node_modules/xmlbuilder": {
"version": "10.1.1", "version": "10.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz",

View File

@ -47,6 +47,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"docx": "^9.5.1",
"docx-preview": "^0.3.6", "docx-preview": "^0.3.6",
"isomorphic-dompurify": "^2.28.0", "isomorphic-dompurify": "^2.28.0",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
@ -54,15 +55,19 @@
"next": "15.4.4", "next": "15.4.4",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.9.0", "react-day-picker": "^9.9.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-resizable-panels": "^3.0.6",
"react-window": "^2.1.0", "react-window": "^2.1.0",
"reactflow": "^11.10.4", "reactflow": "^11.10.4",
"recharts": "^3.2.1", "recharts": "^3.2.1",
"sheetjs-style": "^0.15.8", "sheetjs-style": "^0.15.8",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"uuid": "^13.0.0",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"zod": "^4.1.5" "zod": "^4.1.5"
}, },
@ -73,6 +78,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19.1.13", "@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@types/uuid": "^10.0.0",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.4.4", "eslint-config-next": "15.4.4",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",

263
frontend/types/report.ts Normal file
View File

@ -0,0 +1,263 @@
/**
*
*/
// 리포트 템플릿
export interface ReportTemplate {
template_id: string;
template_name_kor: string;
template_name_eng: string | null;
template_type: string;
is_system: string;
thumbnail_url: string | null;
description: string | null;
layout_config: string | null;
default_queries: string | null;
use_yn: string;
sort_order: number;
created_at: string;
created_by: string | null;
updated_at: string | null;
updated_by: string | null;
}
// 리포트 마스터
export interface ReportMaster {
report_id: string;
report_name_kor: string;
report_name_eng: string | null;
template_id: string | null;
report_type: string;
company_code: string | null;
description: string | null;
use_yn: string;
created_at: string;
created_by: string | null;
updated_at: string | null;
updated_by: string | null;
}
// 리포트 레이아웃
export interface ReportLayout {
layout_id: string;
report_id: string;
canvas_width: number;
canvas_height: number;
page_orientation: string;
margin_top: number;
margin_bottom: number;
margin_left: number;
margin_right: number;
components: ComponentConfig[];
pages?: ReportPage[]; // 새 페이지 구조 (옵셔널, 하위 호환성)
created_at: string;
created_by: string | null;
updated_at: string | null;
updated_by: string | null;
}
// 리포트 쿼리
export interface ReportQuery {
query_id: string;
report_id: string;
query_name: string;
query_type: "MASTER" | "DETAIL";
sql_query: string;
parameters: string[];
external_connection_id: number | null; // 외부 DB 연결 ID
display_order: number;
created_at: string;
created_by: string | null;
updated_at: string | null;
updated_by: string | null;
}
// 외부 DB 연결 (간단한 버전)
export interface ExternalConnection {
id: number;
connection_name: string;
db_type: string;
description?: string;
is_active: string;
}
// 그리드 설정
export interface GridConfig {
cellWidth: number; // 그리드 셀 너비 (px)
cellHeight: number; // 그리드 셀 높이 (px)
rows: number; // 세로 그리드 수 (계산값: pageHeight / cellHeight)
columns: number; // 가로 그리드 수 (계산값: pageWidth / cellHeight)
visible: boolean; // 그리드 표시 여부
snapToGrid: boolean; // 그리드 스냅 활성화 여부
gridColor: string; // 그리드 선 색상
gridOpacity: number; // 그리드 투명도 (0-1)
}
// 페이지 설정
export interface ReportPage {
page_id: string;
page_name: string;
page_order: number;
width: number; // mm
height: number; // mm
orientation: "portrait" | "landscape";
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
background_color: string;
gridConfig?: GridConfig; // 그리드 설정 (옵셔널)
components: ComponentConfig[];
}
// 레이아웃 설정 (페이지 기반)
export interface ReportLayoutConfig {
pages: ReportPage[];
}
// 컴포넌트 설정
export interface ComponentConfig {
id: string;
type: string; // "text", "label", "table", "image", "divider", "signature", "stamp"
x: number;
y: number;
width: number;
height: number;
zIndex: number;
// 그리드 좌표 (옵셔널)
gridX?: number; // 시작 열 (0부터 시작)
gridY?: number; // 시작 행 (0부터 시작)
gridWidth?: number; // 차지하는 열 수
gridHeight?: number; // 차지하는 행 수
fontSize?: number;
fontFamily?: string;
fontWeight?: string;
fontColor?: string;
backgroundColor?: string;
borderWidth?: number;
borderColor?: string;
borderRadius?: number;
textAlign?: string;
padding?: number;
queryId?: string;
fieldName?: string;
defaultValue?: string;
format?: string;
visible?: boolean;
printable?: boolean;
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와 별도)
// 서명/도장 전용
showLabel?: boolean; // 레이블 표시 여부 ("서명:", "(인)")
labelText?: string; // 커스텀 레이블 텍스트
labelPosition?: "top" | "left" | "bottom" | "right"; // 레이블 위치
showUnderline?: boolean; // 서명란 밑줄 표시 여부
personName?: string; // 도장란 이름 (예: "홍길동")
// 테이블 전용
tableColumns?: Array<{
field: string; // 필드명
header: string; // 헤더 표시명
width?: number; // 컬럼 너비 (px)
align?: "left" | "center" | "right"; // 정렬
}>;
headerBackgroundColor?: string; // 헤더 배경색
headerTextColor?: string; // 헤더 텍스트 색상
showBorder?: boolean; // 테두리 표시
rowHeight?: number; // 행 높이 (px)
}
// 리포트 상세
export interface ReportDetail {
report: ReportMaster;
layout: ReportLayout | null;
queries: ReportQuery[];
}
// 리포트 목록 응답
export interface GetReportsResponse {
items: ReportMaster[];
total: number;
page: number;
limit: number;
}
// 리포트 목록 조회 파라미터
export interface GetReportsParams {
page?: number;
limit?: number;
searchText?: string;
reportType?: string;
useYn?: string;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
// 리포트 생성 요청
export interface CreateReportRequest {
reportNameKor: string;
reportNameEng?: string;
templateId?: string;
reportType: string;
description?: string;
companyCode?: string;
}
// 리포트 수정 요청
export interface UpdateReportRequest {
reportNameKor?: string;
reportNameEng?: string;
reportType?: string;
description?: string;
useYn?: string;
}
// 레이아웃 저장 요청
export interface SaveLayoutRequest {
layoutConfig: ReportLayoutConfig; // 페이지 기반 구조
queries?: Array<{
id: string;
name: string;
type: "MASTER" | "DETAIL";
sqlQuery: string;
parameters: string[];
externalConnectionId?: number;
}>;
// 하위 호환성 (deprecated)
canvasWidth?: number;
canvasHeight?: number;
pageOrientation?: string;
marginTop?: number;
marginBottom?: number;
marginLeft?: number;
marginRight?: number;
components?: ComponentConfig[];
}
// 템플릿 목록 응답
export interface GetTemplatesResponse {
system: ReportTemplate[];
custom: ReportTemplate[];
}
// 템플릿 생성 요청
export interface CreateTemplateRequest {
templateNameKor: string;
templateNameEng?: string;
templateType: string;
description?: string;
layoutConfig?: any;
defaultQueries?: any;
}

1234
레포트드자이너.html Normal file

File diff suppressed because it is too large Load Diff