diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index c6a31aa8..12cefea0 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -30,6 +30,7 @@ "oracledb": "^6.9.0", "pg": "^8.16.3", "redis": "^4.6.10", + "uuid": "^13.0.0", "winston": "^3.11.0" }, "devDependencies": { @@ -994,6 +995,15 @@ "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": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -10161,12 +10171,16 @@ } }, "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==", + "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/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { diff --git a/backend-node/package.json b/backend-node/package.json index 910269c1..befdcb15 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -44,6 +44,7 @@ "oracledb": "^6.9.0", "pg": "^8.16.3", "redis": "^4.6.10", + "uuid": "^13.0.0", "winston": "^3.11.0" }, "devDependencies": { diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 608abb51..31f12a32 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -48,6 +48,7 @@ import externalCallRoutes from "./routes/externalCallRoutes"; import externalCallConfigRoutes from "./routes/externalCallConfigRoutes"; import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes"; import dashboardRoutes from "./routes/dashboardRoutes"; +import reportRoutes from "./routes/reportRoutes"; import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -75,21 +76,30 @@ app.use(compression()); app.use(express.json({ limit: "10mb" })); app.use(express.urlencoded({ extended: true, limit: "10mb" })); +// 정적 파일 서빙 전에 CORS 미들웨어 추가 (OPTIONS 요청 처리) +app.options("/uploads/*", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + res.sendStatus(200); +}); + // 정적 파일 서빙 (업로드된 파일들) app.use( "/uploads", - express.static(path.join(process.cwd(), "uploads"), { - setHeaders: (res, path) => { - // 파일 서빙 시 CORS 헤더 설정 - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); - res.setHeader( - "Access-Control-Allow-Headers", - "Content-Type, Authorization" - ); - res.setHeader("Cache-Control", "public, max-age=3600"); - }, - }) + (req, res, next) => { + // 모든 정적 파일 요청에 CORS 헤더 추가 + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, Authorization" + ); + res.setHeader("Cross-Origin-Resource-Policy", "cross-origin"); + res.setHeader("Cache-Control", "public, max-age=3600"); + next(); + }, + express.static(path.join(process.cwd(), "uploads")) ); // CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨 @@ -181,6 +191,7 @@ app.use("/api/external-calls", externalCallRoutes); app.use("/api/external-call-configs", externalCallConfigRoutes); app.use("/api/dataflow", dataflowExecutionRoutes); app.use("/api/dashboards", dashboardRoutes); +app.use("/api/admin/reports", reportRoutes); // app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석 // app.use('/api/users', userRoutes); diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts new file mode 100644 index 00000000..f9162016 --- /dev/null +++ b/backend-node/src/controllers/reportController.ts @@ -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(); diff --git a/backend-node/src/routes/reportRoutes.ts b/backend-node/src/routes/reportRoutes.ts new file mode 100644 index 00000000..76e1a955 --- /dev/null +++ b/backend-node/src/routes/reportRoutes.ts @@ -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; diff --git a/backend-node/src/services/reportService.ts b/backend-node/src/services/reportService.ts new file mode 100644 index 00000000..77087f25 --- /dev/null +++ b/backend-node/src/services/reportService.ts @@ -0,0 +1,1063 @@ +/** + * 리포트 관리 서비스 + */ + +import { v4 as uuidv4 } from "uuid"; +import { query, queryOne, transaction } from "../database/db"; +import { + ReportMaster, + ReportLayout, + ReportQuery, + ReportTemplate, + ReportDetail, + GetReportsParams, + GetReportsResponse, + CreateReportRequest, + UpdateReportRequest, + SaveLayoutRequest, + GetTemplatesResponse, + CreateTemplateRequest, +} from "../types/report"; +import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; +import { ExternalDbConnectionService } from "./externalDbConnectionService"; + +export class ReportService { + /** + * SQL 쿼리 검증 (SELECT만 허용) + */ + private validateQuerySafety(sql: string): void { + // 위험한 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) { + // 단어 경계를 고려하여 검사 (예: DELETE, DELETE FROM 등) + const regex = new RegExp(`\\b${keyword}\\b`, "i"); + if (regex.test(upperSql)) { + throw new Error( + `보안상의 이유로 ${keyword} 명령어는 사용할 수 없습니다. SELECT 쿼리만 허용됩니다.` + ); + } + } + + // SELECT 쿼리인지 확인 + if (!upperSql.startsWith("SELECT") && !upperSql.startsWith("WITH")) { + throw new Error( + "SELECT 쿼리만 허용됩니다. 데이터 조회 용도로만 사용할 수 있습니다." + ); + } + + // 세미콜론으로 구분된 여러 쿼리 방지 + const semicolonCount = (sql.match(/;/g) || []).length; + if ( + semicolonCount > 1 || + (semicolonCount === 1 && !sql.trim().endsWith(";")) + ) { + throw new Error( + "보안상의 이유로 여러 개의 쿼리를 동시에 실행할 수 없습니다." + ); + } + } + + /** + * 리포트 목록 조회 + */ + async getReports(params: GetReportsParams): Promise { + const { + page = 1, + limit = 20, + searchText = "", + reportType = "", + useYn = "Y", + sortBy = "created_at", + sortOrder = "DESC", + } = params; + + const offset = (page - 1) * limit; + + // WHERE 조건 동적 생성 + const conditions: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (useYn) { + conditions.push(`use_yn = $${paramIndex++}`); + values.push(useYn); + } + + if (searchText) { + conditions.push( + `(report_name_kor LIKE $${paramIndex} OR report_name_eng LIKE $${paramIndex})` + ); + values.push(`%${searchText}%`); + paramIndex++; + } + + if (reportType) { + conditions.push(`report_type = $${paramIndex++}`); + values.push(reportType); + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + // 전체 개수 조회 + const countQuery = ` + SELECT COUNT(*) as total + FROM report_master + ${whereClause} + `; + const countResult = await queryOne<{ total: string }>(countQuery, values); + const total = parseInt(countResult?.total || "0", 10); + + // 목록 조회 + const listQuery = ` + SELECT + report_id, + report_name_kor, + report_name_eng, + template_id, + report_type, + company_code, + description, + use_yn, + created_at, + created_by, + updated_at, + updated_by + FROM report_master + ${whereClause} + ORDER BY ${sortBy} ${sortOrder} + LIMIT $${paramIndex++} OFFSET $${paramIndex} + `; + + const items = await query(listQuery, [ + ...values, + limit, + offset, + ]); + + return { + items, + total, + page, + limit, + }; + } + + /** + * 리포트 상세 조회 + */ + async getReportById(reportId: string): Promise { + // 리포트 마스터 조회 + const reportQuery = ` + SELECT + report_id, + report_name_kor, + report_name_eng, + template_id, + report_type, + company_code, + description, + use_yn, + created_at, + created_by, + updated_at, + updated_by + FROM report_master + WHERE report_id = $1 + `; + const report = await queryOne(reportQuery, [reportId]); + + if (!report) { + return null; + } + + // 레이아웃 조회 + const layoutQuery = ` + SELECT + layout_id, + report_id, + canvas_width, + canvas_height, + page_orientation, + margin_top, + margin_bottom, + margin_left, + margin_right, + components, + created_at, + created_by, + updated_at, + updated_by + FROM report_layout + WHERE report_id = $1 + `; + const layout = await queryOne(layoutQuery, [reportId]); + + // 쿼리 조회 + const queriesQuery = ` + SELECT + query_id, + report_id, + query_name, + query_type, + sql_query, + parameters, + external_connection_id, + display_order, + created_at, + created_by, + updated_at, + updated_by + FROM report_query + WHERE report_id = $1 + ORDER BY display_order, created_at + `; + const queries = await query(queriesQuery, [reportId]); + + return { + report, + layout, + queries: queries || [], + }; + } + + /** + * 리포트 생성 + */ + async createReport( + data: CreateReportRequest, + userId: string + ): Promise { + const reportId = `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + + return transaction(async (client) => { + // 리포트 마스터 생성 + const insertReportQuery = ` + INSERT INTO report_master ( + report_id, + report_name_kor, + report_name_eng, + template_id, + report_type, + company_code, + description, + use_yn, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', $8) + `; + + await client.query(insertReportQuery, [ + reportId, + data.reportNameKor, + data.reportNameEng || null, + data.templateId || null, + data.reportType, + data.companyCode || null, + data.description || null, + userId, + ]); + + // 템플릿이 있으면 해당 템플릿의 레이아웃 복사 + if (data.templateId) { + const templateQuery = ` + SELECT layout_config FROM report_template WHERE template_id = $1 + `; + const template = await client.query(templateQuery, [data.templateId]); + + if (template.rows.length > 0 && template.rows[0].layout_config) { + const layoutConfig = JSON.parse(template.rows[0].layout_config); + const layoutId = `LAY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + + const insertLayoutQuery = ` + INSERT INTO report_layout ( + layout_id, + report_id, + canvas_width, + canvas_height, + page_orientation, + margin_top, + margin_bottom, + margin_left, + margin_right, + components, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `; + + await client.query(insertLayoutQuery, [ + layoutId, + reportId, + layoutConfig.width || 210, + layoutConfig.height || 297, + layoutConfig.orientation || "portrait", + 20, + 20, + 20, + 20, + JSON.stringify([]), + userId, + ]); + } + } + + return reportId; + }); + } + + /** + * 리포트 수정 + */ + async updateReport( + reportId: string, + data: UpdateReportRequest, + userId: string + ): Promise { + const setClauses: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (data.reportNameKor !== undefined) { + setClauses.push(`report_name_kor = $${paramIndex++}`); + values.push(data.reportNameKor); + } + + if (data.reportNameEng !== undefined) { + setClauses.push(`report_name_eng = $${paramIndex++}`); + values.push(data.reportNameEng); + } + + if (data.reportType !== undefined) { + setClauses.push(`report_type = $${paramIndex++}`); + values.push(data.reportType); + } + + if (data.description !== undefined) { + setClauses.push(`description = $${paramIndex++}`); + values.push(data.description); + } + + if (data.useYn !== undefined) { + setClauses.push(`use_yn = $${paramIndex++}`); + values.push(data.useYn); + } + + if (setClauses.length === 0) { + return false; + } + + setClauses.push(`updated_at = CURRENT_TIMESTAMP`); + setClauses.push(`updated_by = $${paramIndex++}`); + values.push(userId); + + values.push(reportId); + + const updateQuery = ` + UPDATE report_master + SET ${setClauses.join(", ")} + WHERE report_id = $${paramIndex} + `; + + const result = await query(updateQuery, values); + return true; + } + + /** + * 리포트 삭제 + */ + async deleteReport(reportId: string): Promise { + return transaction(async (client) => { + // 쿼리 삭제 (CASCADE로 자동 삭제되지만 명시적으로) + await client.query(`DELETE FROM report_query WHERE report_id = $1`, [ + reportId, + ]); + + // 레이아웃 삭제 + await client.query(`DELETE FROM report_layout WHERE report_id = $1`, [ + reportId, + ]); + + // 리포트 마스터 삭제 + const result = await client.query( + `DELETE FROM report_master WHERE report_id = $1`, + [reportId] + ); + + return (result.rowCount ?? 0) > 0; + }); + } + + /** + * 리포트 복사 + */ + async copyReport(reportId: string, userId: string): Promise { + return transaction(async (client) => { + // 원본 리포트 조회 + const originalQuery = ` + SELECT * FROM report_master WHERE report_id = $1 + `; + const originalResult = await client.query(originalQuery, [reportId]); + + if (originalResult.rows.length === 0) { + return null; + } + + const original = originalResult.rows[0]; + const newReportId = `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + + // 리포트 마스터 복사 + const copyReportQuery = ` + INSERT INTO report_master ( + report_id, + report_name_kor, + report_name_eng, + template_id, + report_type, + company_code, + description, + use_yn, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `; + + await client.query(copyReportQuery, [ + newReportId, + `${original.report_name_kor} (복사)`, + original.report_name_eng ? `${original.report_name_eng} (Copy)` : null, + original.template_id, + original.report_type, + original.company_code, + original.description, + original.use_yn, + userId, + ]); + + // 레이아웃 복사 + const layoutQuery = ` + SELECT * FROM report_layout WHERE report_id = $1 + `; + const layoutResult = await client.query(layoutQuery, [reportId]); + + if (layoutResult.rows.length > 0) { + const originalLayout = layoutResult.rows[0]; + const newLayoutId = `LAY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + + const copyLayoutQuery = ` + INSERT INTO report_layout ( + layout_id, + report_id, + canvas_width, + canvas_height, + page_orientation, + margin_top, + margin_bottom, + margin_left, + margin_right, + components, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `; + + await client.query(copyLayoutQuery, [ + newLayoutId, + newReportId, + originalLayout.canvas_width, + originalLayout.canvas_height, + originalLayout.page_orientation, + originalLayout.margin_top, + originalLayout.margin_bottom, + originalLayout.margin_left, + originalLayout.margin_right, + JSON.stringify(originalLayout.components), + userId, + ]); + } + + // 쿼리 복사 + const queriesQuery = ` + SELECT * FROM report_query WHERE report_id = $1 ORDER BY display_order + `; + const queriesResult = await client.query(queriesQuery, [reportId]); + + if (queriesResult.rows.length > 0) { + const copyQuerySql = ` + INSERT INTO report_query ( + query_id, + report_id, + query_name, + query_type, + sql_query, + parameters, + external_connection_id, + display_order, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `; + + for (const originalQuery of queriesResult.rows) { + const newQueryId = `QRY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + await client.query(copyQuerySql, [ + newQueryId, + newReportId, + originalQuery.query_name, + originalQuery.query_type, + originalQuery.sql_query, + JSON.stringify(originalQuery.parameters), + originalQuery.external_connection_id || null, + originalQuery.display_order, + userId, + ]); + } + } + + return newReportId; + }); + } + + /** + * 레이아웃 조회 + */ + async getLayout(reportId: string): Promise { + const layoutQuery = ` + SELECT + layout_id, + report_id, + canvas_width, + canvas_height, + page_orientation, + margin_top, + margin_bottom, + margin_left, + margin_right, + components, + created_at, + created_by, + updated_at, + updated_by + FROM report_layout + WHERE report_id = $1 + `; + + return queryOne(layoutQuery, [reportId]); + } + + /** + * 레이아웃 저장 (쿼리 포함) + */ + async saveLayout( + reportId: string, + data: SaveLayoutRequest, + userId: string + ): Promise { + return transaction(async (client) => { + // 1. 레이아웃 저장 + const existingQuery = ` + SELECT layout_id FROM report_layout WHERE report_id = $1 + `; + const existing = await client.query(existingQuery, [reportId]); + + if (existing.rows.length > 0) { + // 업데이트 + const updateQuery = ` + UPDATE report_layout + SET + canvas_width = $1, + canvas_height = $2, + page_orientation = $3, + margin_top = $4, + margin_bottom = $5, + margin_left = $6, + margin_right = $7, + components = $8, + updated_at = CURRENT_TIMESTAMP, + updated_by = $9 + WHERE report_id = $10 + `; + + await client.query(updateQuery, [ + data.canvasWidth, + data.canvasHeight, + data.pageOrientation, + data.marginTop, + data.marginBottom, + data.marginLeft, + data.marginRight, + JSON.stringify(data.components), + userId, + reportId, + ]); + } else { + // 생성 + const layoutId = `LAY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + const insertQuery = ` + INSERT INTO report_layout ( + layout_id, + report_id, + canvas_width, + canvas_height, + page_orientation, + margin_top, + margin_bottom, + margin_left, + margin_right, + components, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `; + + await client.query(insertQuery, [ + layoutId, + reportId, + data.canvasWidth, + data.canvasHeight, + data.pageOrientation, + data.marginTop, + data.marginBottom, + data.marginLeft, + data.marginRight, + JSON.stringify(data.components), + userId, + ]); + } + + // 2. 쿼리 저장 (있는 경우) + if (data.queries && data.queries.length > 0) { + // 기존 쿼리 모두 삭제 + await client.query(`DELETE FROM report_query WHERE report_id = $1`, [ + reportId, + ]); + + // 새 쿼리 삽입 + const insertQuerySql = ` + INSERT INTO report_query ( + query_id, + report_id, + query_name, + query_type, + sql_query, + parameters, + external_connection_id, + display_order, + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `; + + for (let i = 0; i < data.queries.length; i++) { + const q = data.queries[i]; + await client.query(insertQuerySql, [ + q.id, + reportId, + q.name, + q.type, + q.sqlQuery, + JSON.stringify(q.parameters), + (q as any).externalConnectionId || null, // 외부 DB 연결 ID + i, + userId, + ]); + } + } + + return true; + }); + } + + /** + * 쿼리 실행 (내부 DB 또는 외부 DB) + */ + async executeQuery( + reportId: string, + queryId: string, + parameters: Record, + sqlQuery?: string, + externalConnectionId?: number | null + ): Promise<{ fields: string[]; rows: any[] }> { + let sql_query: string; + let queryParameters: string[] = []; + let connectionId: number | null = externalConnectionId ?? null; + + // 테스트 모드 (sqlQuery 직접 전달) + if (sqlQuery) { + sql_query = sqlQuery; + // 파라미터 순서 추출 (등장 순서대로) + const matches = sqlQuery.match(/\$\d+/g); + if (matches) { + const seen = new Set(); + const result: string[] = []; + for (const match of matches) { + if (!seen.has(match)) { + seen.add(match); + result.push(match); + } + } + queryParameters = result; + } + } else { + // DB에서 쿼리 조회 + const queryResult = await queryOne( + `SELECT * FROM report_query WHERE query_id = $1 AND report_id = $2`, + [queryId, reportId] + ); + + if (!queryResult) { + throw new Error("쿼리를 찾을 수 없습니다."); + } + + sql_query = queryResult.sql_query; + queryParameters = Array.isArray(queryResult.parameters) + ? queryResult.parameters + : []; + connectionId = queryResult.external_connection_id; + } + + // SQL 쿼리 안전성 검증 (SELECT만 허용) + this.validateQuerySafety(sql_query); + + // 파라미터 배열 생성 ($1, $2 순서대로) + const paramArray: any[] = []; + for (const param of queryParameters) { + paramArray.push(parameters[param] || null); + } + + try { + let result: any[]; + + // 외부 DB 연결이 있으면 외부 DB에서 실행 + if (connectionId) { + // 외부 DB 연결 정보 조회 + const connectionResult = + await ExternalDbConnectionService.getConnectionById(connectionId); + + if (!connectionResult.success || !connectionResult.data) { + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); + } + + const connection = connectionResult.data; + + // DatabaseConnectorFactory를 사용하여 외부 DB 쿼리 실행 + const config = { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + connectionTimeout: connection.connection_timeout || 30000, + queryTimeout: connection.query_timeout || 30000, + }; + + const connector = await DatabaseConnectorFactory.createConnector( + connection.db_type, + config, + connectionId + ); + + await connector.connect(); + + try { + const queryResult = await connector.executeQuery(sql_query); + result = queryResult.rows || []; + } finally { + await connector.disconnect(); + } + } else { + // 내부 DB에서 실행 + result = await query(sql_query, paramArray); + } + + // 필드명 추출 + const fields = result.length > 0 ? Object.keys(result[0]) : []; + + return { + fields, + rows: result, + }; + } catch (error: any) { + throw new Error(`쿼리 실행 오류: ${error.message}`); + } + } + + /** + * 템플릿 목록 조회 + */ + async getTemplates(): Promise { + const templateQuery = ` + SELECT + template_id, + template_name_kor, + template_name_eng, + template_type, + is_system, + thumbnail_url, + description, + layout_config, + default_queries, + use_yn, + sort_order, + created_at, + created_by, + updated_at, + updated_by + FROM report_template + WHERE use_yn = 'Y' + ORDER BY is_system DESC, sort_order ASC + `; + + const templates = await query(templateQuery); + + const system = templates.filter((t) => t.is_system === "Y"); + const custom = templates.filter((t) => t.is_system === "N"); + + return { system, custom }; + } + + /** + * 템플릿 생성 (사용자 정의) + */ + async createTemplate( + data: CreateTemplateRequest, + userId: string + ): Promise { + const templateId = `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + + const insertQuery = ` + INSERT INTO report_template ( + template_id, + template_name_kor, + template_name_eng, + template_type, + is_system, + description, + layout_config, + default_queries, + use_yn, + created_by + ) VALUES ($1, $2, $3, $4, 'N', $5, $6, $7, 'Y', $8) + `; + + await query(insertQuery, [ + templateId, + data.templateNameKor, + data.templateNameEng || null, + data.templateType, + data.description || null, + data.layoutConfig ? JSON.stringify(data.layoutConfig) : null, + data.defaultQueries ? JSON.stringify(data.defaultQueries) : null, + userId, + ]); + + return templateId; + } + + /** + * 템플릿 삭제 (사용자 정의만 가능) + */ + async deleteTemplate(templateId: string): Promise { + const deleteQuery = ` + DELETE FROM report_template + WHERE template_id = $1 AND is_system = 'N' + `; + + const result = await query(deleteQuery, [templateId]); + return true; + } + + /** + * 현재 리포트를 템플릿으로 저장 + */ + async saveAsTemplate( + reportId: string, + templateNameKor: string, + templateNameEng: string | null | undefined, + description: string | null | undefined, + userId: string + ): Promise { + return transaction(async (client) => { + // 리포트 정보 조회 + const reportQuery = ` + SELECT report_type FROM report_master WHERE report_id = $1 + `; + const reportResult = await client.query(reportQuery, [reportId]); + + if (reportResult.rows.length === 0) { + throw new Error("리포트를 찾을 수 없습니다."); + } + + const reportType = reportResult.rows[0].report_type; + + // 레이아웃 조회 + const layoutQuery = ` + SELECT + canvas_width, + canvas_height, + page_orientation, + margin_top, + margin_bottom, + margin_left, + margin_right, + components + FROM report_layout + WHERE report_id = $1 + `; + const layoutResult = await client.query(layoutQuery, [reportId]); + + if (layoutResult.rows.length === 0) { + throw new Error("레이아웃을 찾을 수 없습니다."); + } + + const layout = layoutResult.rows[0]; + + // 쿼리 조회 + const queriesQuery = ` + SELECT + query_name, + query_type, + sql_query, + parameters, + external_connection_id, + display_order + FROM report_query + WHERE report_id = $1 + ORDER BY display_order + `; + const queriesResult = await client.query(queriesQuery, [reportId]); + + // 레이아웃 설정 JSON 생성 + const layoutConfig = { + width: layout.canvas_width, + height: layout.canvas_height, + orientation: layout.page_orientation, + margins: { + top: layout.margin_top, + bottom: layout.margin_bottom, + left: layout.margin_left, + right: layout.margin_right, + }, + components: JSON.parse(layout.components || "[]"), + }; + + // 기본 쿼리 JSON 생성 + const defaultQueries = queriesResult.rows.map((q) => ({ + name: q.query_name, + type: q.query_type, + sqlQuery: q.sql_query, + parameters: Array.isArray(q.parameters) ? q.parameters : [], + externalConnectionId: q.external_connection_id, + displayOrder: q.display_order, + })); + + // 템플릿 생성 + const templateId = `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + + const insertQuery = ` + INSERT INTO report_template ( + template_id, + template_name_kor, + template_name_eng, + template_type, + is_system, + description, + layout_config, + default_queries, + use_yn, + sort_order, + created_by + ) VALUES ($1, $2, $3, $4, 'N', $5, $6, $7, 'Y', 999, $8) + `; + + await client.query(insertQuery, [ + templateId, + templateNameKor, + templateNameEng || null, + reportType, + description || null, + JSON.stringify(layoutConfig), + JSON.stringify(defaultQueries), + userId, + ]); + + return templateId; + }); + } + + // 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요) + async createTemplateFromLayout( + templateNameKor: string, + templateNameEng: string | null | undefined, + templateType: string, + description: string | null | undefined, + 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; + }>, + userId: string + ): Promise { + const templateId = `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + + const insertQuery = ` + INSERT INTO report_template ( + template_id, + template_name_kor, + template_name_eng, + template_type, + is_system, + description, + layout_config, + default_queries, + use_yn, + sort_order, + created_by + ) VALUES ($1, $2, $3, $4, 'N', $5, $6, $7, 'Y', 999, $8) + RETURNING template_id + `; + + await query(insertQuery, [ + templateId, + templateNameKor, + templateNameEng || null, + templateType, + description || null, + JSON.stringify(layoutConfig), + JSON.stringify(defaultQueries), + userId, + ]); + + return templateId; + } +} + +export default new ReportService(); diff --git a/backend-node/src/types/report.ts b/backend-node/src/types/report.ts new file mode 100644 index 00000000..77cc35d7 --- /dev/null +++ b/backend-node/src/types/report.ts @@ -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; +} diff --git a/docs/report-grid-system-implementation-plan.md b/docs/report-grid-system-implementation-plan.md new file mode 100644 index 00000000..31bc4d82 --- /dev/null +++ b/docs/report-grid-system-implementation-plan.md @@ -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 ( + + {/* 세로 선 */} + {Array.from({ length: gridConfig.columns + 1 }).map((_, i) => ( + + ))} + {/* 가로 선 */} + {Array.from({ length: gridConfig.rows + 1 }).map((_, i) => ( + + ))} + + ); +} +``` + +#### 2.2 Canvas 통합 + +- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx` +- **내용**: + - `` 추가 + - 컴포넌트 렌더링 시 그리드 기반 위치 사용 + +### 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 + { + 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 ( + + + 그리드 설정 + + + {/* 그리드 표시 */} +
+ + updateGridConfig({ visible })} + /> +
+ + {/* 스냅 활성화 */} +
+ + updateGridConfig({ snapToGrid })} + /> +
+ + {/* 셀 크기 */} +
+ + + updateGridConfig({ cellWidth: parseInt(e.target.value) }) + } + min={10} + max={100} + /> +
+ +
+ + + updateGridConfig({ cellHeight: parseInt(e.target.value) }) + } + min={10} + max={100} + /> +
+ + {/* 프리셋 */} +
+ + +
+
+
+ ); +} +``` + +#### 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 diff --git a/docs/리포트_관리_시스템_구현_진행상황.md b/docs/리포트_관리_시스템_구현_진행상황.md new file mode 100644 index 00000000..2563a6eb --- /dev/null +++ b/docs/리포트_관리_시스템_구현_진행상황.md @@ -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% 완료) diff --git a/docs/리포트_관리_시스템_설계.md b/docs/리포트_관리_시스템_설계.md new file mode 100644 index 00000000..827ef7ea --- /dev/null +++ b/docs/리포트_관리_시스템_설계.md @@ -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/) diff --git a/docs/리포트_문서번호_채번_시스템_설계.md b/docs/리포트_문서번호_채번_시스템_설계.md new file mode 100644 index 00000000..7222f653 --- /dev/null +++ b/docs/리포트_문서번호_채번_시스템_설계.md @@ -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; + + // 문서 번호 형식 검증 + static async validateNumber(documentNumber: string): Promise; + + // 문서 번호 중복 체크 + static async isDuplicate(documentNumber: string): Promise; + + // 문서 번호 무효화 + static async voidNumber( + documentNumber: string, + reason: string, + userId: string + ): Promise; + + // 특정 규칙의 다음 번호 미리보기 + static async previewNextNumber( + ruleId: number, + deptCode?: string + ): Promise; +} +``` + +### 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 { + // 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 +
+ + {documentNumber || "저장 시 자동 생성"} +
+``` + +## 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) + diff --git a/docs/리포트_페이지_관리_시스템_설계.md b/docs/리포트_페이지_관리_시스템_설계.md new file mode 100644 index 00000000..cad99adf --- /dev/null +++ b/docs/리포트_페이지_관리_시스템_설계.md @@ -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) => 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 +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 페이지 시스템 diff --git a/frontend/app/(main)/admin/report/designer/[reportId]/page.tsx b/frontend/app/(main)/admin/report/designer/[reportId]/page.tsx new file mode 100644 index 00000000..03d5bcd9 --- /dev/null +++ b/frontend/app/(main)/admin/report/designer/[reportId]/page.tsx @@ -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 ( +
+ +
+ ); + } + + return ( + + +
+ {/* 상단 툴바 */} + + + {/* 메인 영역 */} +
+ {/* 페이지 목록 패널 */} + + + {/* 좌측 패널 (템플릿, 컴포넌트) */} + + + {/* 중앙 캔버스 */} + + + {/* 우측 패널 (속성) */} + +
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/report/page.tsx b/frontend/app/(main)/admin/report/page.tsx new file mode 100644 index 00000000..37270683 --- /dev/null +++ b/frontend/app/(main)/admin/report/page.tsx @@ -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 ( +
+
+ {/* 페이지 제목 */} +
+
+

리포트 관리

+

리포트를 생성하고 관리합니다

+
+ +
+ + {/* 검색 영역 */} + + + + + 검색 + + + +
+ setSearchText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleSearchClick(); + } + }} + className="flex-1" + /> + + +
+
+
+ + {/* 리포트 목록 */} + + + + + 📋 리포트 목록 + (총 {total}건) + + + + + + + +
+
+ ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index fa3a934d..8352502a 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -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 "tw-animate-css"; @@ -76,7 +79,7 @@ --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); - + /* Z-Index 계층 구조 */ --z-background: 1; --z-layout: 10; diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 11470e80..3709c650 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -23,6 +23,9 @@ export const metadata: Metadata = { description: "제품 수명 주기 관리(PLM) 솔루션", keywords: ["WACE", "PLM", "Product Lifecycle Management", "WACE", "제품관리"], authors: [{ name: "WACE" }], + icons: { + icon: "/favicon.ico", + }, }; export const viewport: Viewport = { @@ -37,10 +40,6 @@ export default function RootLayout({ }>) { return ( - - - -
diff --git a/frontend/components/report/ReportCreateModal.tsx b/frontend/components/report/ReportCreateModal.tsx new file mode 100644 index 00000000..df9810c8 --- /dev/null +++ b/frontend/components/report/ReportCreateModal.tsx @@ -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({ + reportNameKor: "", + reportNameEng: "", + templateId: undefined, + reportType: "BASIC", + description: "", + }); + const [templates, setTemplates] = useState([]); + 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 ( + + + + 새 리포트 생성 + 새로운 리포트를 생성합니다. 필수 항목을 입력해주세요. + + +
+ {/* 리포트명 (한글) */} +
+ + setFormData({ ...formData, reportNameKor: e.target.value })} + /> +
+ + {/* 리포트명 (영문) */} +
+ + setFormData({ ...formData, reportNameEng: e.target.value })} + /> +
+ + {/* 템플릿 선택 */} +
+ + +
+ + {/* 리포트 타입 */} +
+ + +
+ + {/* 설명 */} +
+ +
'; + + container.appendChild(card); + + // 쿼리 텍스트가 변경될 때마다 파라미터를 감지합니다 + const textarea = card.querySelector(".query-textarea"); + textarea.addEventListener("input", function () { + detectQueryParameters(queryId); + }); + + // 초기 SQL이 있으면 파라미터를 감지합니다 + if (queries[queryId].sql) { + detectQueryParameters(queryId); + } + } + + // 쿼리 이름을 업데이트하는 함수 + function updateQueryName(queryId, name) { + queries[queryId].name = name; + const nameSpan = document.querySelector("#" + queryId + " .query-name"); + nameSpan.textContent = name; + } + + // 쿼리 타입을 업데이트하는 함수 + function updateQueryType(queryId, type) { + queries[queryId].type = type; + const card = document.getElementById(queryId); + card.className = "query-card " + type; + const badge = card.querySelector(".query-type-badge"); + badge.className = "query-type-badge " + type; + badge.textContent = type === "master" ? "MASTER" : "DETAIL"; + } + + // 쿼리 SQL을 업데이트하는 함수 + function updateQuerySql(queryId, sql) { + queries[queryId].sql = sql; + } + + // 쿼리를 삭제하는 함수 + function deleteQuery(queryId) { + if (confirm("이 쿼리를 삭제하시겠습니까?")) { + document.getElementById(queryId).remove(); + delete queries[queryId]; + } + } + + // 쿼리에서 파라미터를 감지하는 함수 + function detectQueryParameters(queryId) { + const card = document.getElementById(queryId); + const textarea = card.querySelector(".query-textarea"); + const sql = textarea.value; + const paramSection = document.getElementById("params-" + queryId); + + const regex = /\$\d+/g; + const matches = sql.match(regex); + + if (matches && matches.length > 0) { + const uniqueParams = Array.from(new Set(matches)); + uniqueParams.sort(function (a, b) { + return parseInt(a.substring(1)) - parseInt(b.substring(1)); + }); + + paramSection.classList.add("show"); + paramSection.innerHTML = + '📎 파라미터'; + + uniqueParams.forEach(function (param) { + const paramNum = param.substring(1); + const fieldDiv = document.createElement("div"); + fieldDiv.className = "parameter-field"; + fieldDiv.style.display = "flex"; + fieldDiv.style.gap = "5px"; + fieldDiv.style.alignItems = "center"; + + const label = document.createElement("div"); + label.textContent = param; + label.style.minWidth = "35px"; + label.style.fontWeight = "bold"; + label.style.fontSize = "11px"; + + const select = document.createElement("select"); + select.className = "param-type-select"; + select.innerHTML = + ''; + + const input = document.createElement("input"); + input.type = "text"; + input.className = "param-value-input"; + input.placeholder = "값"; + input.dataset.param = param; + input.dataset.queryId = queryId; + + // URL 파라미터가 있으면 자동으로 채웁니다 + const urlParams = loadUrlParameters(); + if (urlParams[param]) { + input.value = urlParams[param]; + } + + select.addEventListener("change", function () { + input.type = select.value; + }); + + fieldDiv.appendChild(label); + fieldDiv.appendChild(select); + fieldDiv.appendChild(input); + paramSection.appendChild(fieldDiv); + }); + + updateUrlExample(); + } else { + paramSection.classList.remove("show"); + } + } + + // URL 예시를 업데이트하는 함수 + function updateUrlExample() { + const allParams = []; + + // 모든 쿼리의 파라미터를 수집합니다 + Object.keys(queries).forEach(function (queryId) { + const paramSection = document.getElementById("params-" + queryId); + if (paramSection) { + const inputs = paramSection.querySelectorAll("input[data-param]"); + inputs.forEach(function (input) { + const param = input.dataset.param; + if (!allParams.includes(param)) { + allParams.push(param); + } + }); + } + }); + + // URL 예시를 생성합니다 + if (allParams.length > 0) { + allParams.sort(function (a, b) { + return parseInt(a.substring(1)) - parseInt(b.substring(1)); + }); + + const paramString = allParams + .map(function (p) { + return p + "=값"; + }) + .join("&"); + + document.getElementById("url-sample").textContent = + window.location.pathname + "?" + paramString; + } + } + + // 쿼리를 실행하는 함수 (시뮬레이션) + function executeQuery(queryId) { + const query = queries[queryId]; + let finalSql = query.sql; + + // 파라미터를 실제 값으로 치환합니다 + const paramSection = document.getElementById("params-" + queryId); + const inputs = paramSection.querySelectorAll("input[data-param]"); + let allFilled = true; + + inputs.forEach(function (input) { + const param = input.dataset.param; + const value = input.value; + const type = input.parentElement.querySelector("select").value; + + if (value) { + let formattedValue = value; + if (type === "text" || type === "date") { + formattedValue = "'" + value + "'"; + } + finalSql = finalSql.split(param).join(formattedValue); + } else { + allFilled = false; + } + }); + + if (!allFilled) { + alert("모든 파라미터 값을 입력해주세요!"); + return; + } + + console.log("[" + query.name + "] 실행 쿼리:", finalSql); + + // 결과 영역에 샘플 필드를 표시합니다 + const resultArea = document.getElementById("result-" + queryId); + resultArea.style.display = "block"; + const fieldsDiv = resultArea.querySelector(".result-fields"); + + // 샘플 필드를 생성합니다 + if (query.type === "master") { + fieldsDiv.innerHTML = + '
order_no
order_date
supplier
'; + } else { + fieldsDiv.innerHTML = + '
item_name
quantity
price
'; + } + + alert("쿼리가 실행되었습니다!\n콘솔에서 확인하세요."); + } + + // 템플릿을 적용하는 함수 + function applyTemplate(templateType) { + clearCanvas(); + + switch (templateType) { + case "order": + // 발주서 템플릿: 마스터 쿼리와 디테일 쿼리를 모두 생성합니다 + addQuery({ + name: "발주 마스터", + type: "master", + sql: "SELECT order_no, order_date, supplier FROM purchase_order WHERE order_no = $1", + }); + + addQuery({ + name: "발주 상세", + type: "detail", + sql: "SELECT item_name, quantity, price FROM purchase_order_detail WHERE order_no = $1", + }); + + // 캔버스에 컴포넌트를 배치합니다 + createElement("label", 50, 80, { width: "150px", height: "40px" }); + createElement("text", 210, 80, { + width: "300px", + height: "40px", + queryId: "query_1", + }); + + createElement("label", 50, 140, { width: "150px", height: "40px" }); + createElement("text", 210, 140, { + width: "300px", + height: "40px", + queryId: "query_1", + }); + + createElement("table", 50, 220, { + width: "650px", + height: "300px", + queryId: "query_2", + }); + break; + + case "invoice": + // 청구서 템플릿 + addQuery({ + name: "청구 마스터", + type: "master", + sql: "SELECT invoice_no, invoice_date, customer FROM invoice WHERE invoice_no = $1", + }); + + addQuery({ + name: "청구 항목", + type: "detail", + sql: "SELECT description, amount FROM invoice_items WHERE invoice_no = $1", + }); + + createElement("text", 100, 100); + createElement("table", 100, 250, { + width: "600px", + height: "250px", + }); + break; + + case "basic": + addQuery({ + name: "기본 쿼리", + type: "master", + sql: "SELECT * FROM table WHERE id = $1", + }); + createElement("text", 100, 100); + break; + } + } + + // 파라미터 테스트 함수 - 실제 URL 파라미터를 시뮬레이션합니다 + function testWithParams() { + const testUrl = window.location.pathname + "?$1=PO-2025-001&$2=2025-01"; + alert( + "테스트 URL:\n" + + testUrl + + "\n\n이 URL로 페이지를 열면 파라미터가 자동으로 입력됩니다." + ); + + // 실제로 URL을 변경합니다 + if (confirm("테스트 파라미터로 페이지를 새로고침하시겠습니까?")) { + window.location.href = testUrl; + } + } + + function clearCanvas() { + const components = canvas.querySelectorAll(".placed-component"); + components.forEach(function (comp) { + comp.remove(); + }); + } + + function saveReport() { + const reportData = { + title: document.getElementById("report-title").value, + queries: queries, + components: [], + }; + + document.querySelectorAll(".placed-component").forEach(function (comp) { + reportData.components.push({ + type: comp.dataset.type, + queryId: comp.dataset.queryId, + left: comp.style.left, + top: comp.style.top, + width: comp.style.width, + height: comp.style.height, + }); + }); + + console.log("저장된 리포트:", JSON.stringify(reportData, null, 2)); + alert("리포트가 저장되었습니다!\n콘솔에서 확인하세요."); + } + + function showPreview() { + const modal = document.getElementById("previewModal"); + const previewContent = document.getElementById("previewContent"); + previewContent.innerHTML = canvas.innerHTML; + modal.classList.add("active"); + } + + function closePreview() { + document.getElementById("previewModal").classList.remove("active"); + } + + function exportPDF() { + alert("PDF 다운로드 기능이 실행됩니다."); + } + + function exportWord() { + alert("WORD 다운로드 기능이 실행됩니다."); + } + + document + .getElementById("previewModal") + .addEventListener("click", function (e) { + if (e.target === this) { + closePreview(); + } + }); + + +