From ede881f8ff521bf2bb2c9e96ba6585352d97d867 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 1 Oct 2025 11:10:54 +0900 Subject: [PATCH 01/53] =?UTF-8?q?=EB=A6=AC=ED=8F=AC=ED=8A=B8=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/리포트_관리_시스템_설계.md | 679 +++++++++++++++++ 레포트드자이너.html | 1234 +++++++++++++++++++++++++++++++ 2 files changed, 1913 insertions(+) create mode 100644 docs/리포트_관리_시스템_설계.md create mode 100644 레포트드자이너.html 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/레포트드자이너.html b/레포트드자이너.html new file mode 100644 index 00000000..a9107d56 --- /dev/null +++ b/레포트드자이너.html @@ -0,0 +1,1234 @@ + + + + + + 리포트 디자이너 + + + +
+

📄 리포트 디자이너

+ + + +
+ +
+
+
+

기본 템플릿

+
+ 📋 발주서 +
+
+ 💰 청구서 +
+
+ 📄 기본 +
+
+ +
+

컴포넌트

+
+ 📝 텍스트 +
+
+ 📊 테이블 +
+
+ 🏷 레이블 +
+
+
+ +
+
+
작업 영역
+

+ 왼쪽에서 컴포넌트를 드래그하거나 템플릿을 선택하세요 +

+
+
+ +
+
+

입력창

+
+ + +
+
+ +
+

쿼리 관리

+ + +
+ +
+ +
+ 💡 마스터 쿼리는 1건의 데이터를 가져오고, + 디테일 쿼리는 여러 건의 데이터를 반복 표시합니다. + 발주서의 경우 상단 헤더 정보는 마스터, 하단 품목 리스트는 디테일로 + 설정하세요. +
+
+ +
+

외부 호출 정보

+
+ 🔗 URL로 파라미터 전달 방법 +
+ 다른 프로그램에서 이 리포트를 호출할 때는 URL에 파라미터를 + 추가하세요. + report.html?$1=admin&$2=2020-12 +
+
+
+
+
+ + + + + + From 213f482a6fd8e609f6beb60f785552a9704419a8 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 1 Oct 2025 11:33:55 +0900 Subject: [PATCH 02/53] uuid install --- backend-node/package-lock.json | 30 ++++++++++++++++++++++++++---- backend-node/package.json | 2 ++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 2619199d..18b25b2e 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -29,6 +29,7 @@ "oracledb": "^6.9.0", "pg": "^8.16.3", "redis": "^4.6.10", + "uuid": "^13.0.0", "winston": "^3.11.0" }, "devDependencies": { @@ -48,6 +49,7 @@ "@types/pg": "^8.15.5", "@types/sanitize-html": "^2.9.5", "@types/supertest": "^6.0.3", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "eslint": "^8.55.0", @@ -991,6 +993,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", @@ -3544,6 +3555,13 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -10243,12 +10261,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 9d892e3f..5f5b4b4b 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -47,6 +47,7 @@ "oracledb": "^6.9.0", "pg": "^8.16.3", "redis": "^4.6.10", + "uuid": "^13.0.0", "winston": "^3.11.0" }, "devDependencies": { @@ -66,6 +67,7 @@ "@types/pg": "^8.15.5", "@types/sanitize-html": "^2.9.5", "@types/supertest": "^6.0.3", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^6.14.0", "@typescript-eslint/parser": "^6.14.0", "eslint": "^8.55.0", From bd72f7892b5eebe094ebc2e86a464cb2f7ac7ecd Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 1 Oct 2025 11:34:17 +0900 Subject: [PATCH 03/53] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20api=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../src/controllers/reportController.ts | 333 ++++++++++ backend-node/src/routes/reportRoutes.ts | 60 ++ backend-node/src/services/reportService.ts | 610 ++++++++++++++++++ backend-node/src/types/report.ts | 129 ++++ 5 files changed, 1134 insertions(+) create mode 100644 backend-node/src/controllers/reportController.ts create mode 100644 backend-node/src/routes/reportRoutes.ts create mode 100644 backend-node/src/services/reportService.ts create mode 100644 backend-node/src/types/report.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 3c8974d4..ba54ab36 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -43,6 +43,7 @@ import entityReferenceRoutes from "./routes/entityReferenceRoutes"; import externalCallRoutes from "./routes/externalCallRoutes"; import externalCallConfigRoutes from "./routes/externalCallConfigRoutes"; import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes"; +import reportRoutes from "./routes/reportRoutes"; import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -171,6 +172,7 @@ app.use("/api/entity-reference", entityReferenceRoutes); app.use("/api/external-calls", externalCallRoutes); app.use("/api/external-call-configs", externalCallConfigRoutes); app.use("/api/dataflow", dataflowExecutionRoutes); +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..6a12df95 --- /dev/null +++ b/backend-node/src/controllers/reportController.ts @@ -0,0 +1,333 @@ +/** + * 리포트 관리 컨트롤러 + */ + +import { Request, Response, NextFunction } from "express"; +import reportService from "../services/reportService"; +import { + CreateReportRequest, + UpdateReportRequest, + SaveLayoutRequest, + CreateTemplateRequest, +} from "../types/report"; + +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", + }); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + 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: "리포트를 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + data: report, + }); + } catch (error) { + 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); + + res.status(201).json({ + success: true, + data: { + reportId, + }, + message: "리포트가 생성되었습니다.", + }); + } catch (error) { + 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: "수정할 내용이 없습니다.", + }); + } + + res.json({ + success: true, + message: "리포트가 수정되었습니다.", + }); + } catch (error) { + 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: "리포트를 찾을 수 없습니다.", + }); + } + + res.json({ + success: true, + message: "리포트가 삭제되었습니다.", + }); + } catch (error) { + 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: "리포트를 찾을 수 없습니다.", + }); + } + + res.status(201).json({ + success: true, + data: { + reportId: newReportId, + }, + message: "리포트가 복사되었습니다.", + }); + } catch (error) { + 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) : [], + }; + + res.json({ + success: true, + data: layoutData, + }); + } catch (error) { + 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); + + res.json({ + success: true, + message: "레이아웃이 저장되었습니다.", + }); + } catch (error) { + next(error); + } + } + + /** + * 템플릿 목록 조회 + * GET /api/admin/reports/templates + */ + async getTemplates(req: Request, res: Response, next: NextFunction) { + try { + const templates = await reportService.getTemplates(); + + res.json({ + success: true, + data: templates, + }); + } catch (error) { + 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); + + res.status(201).json({ + success: true, + data: { + templateId, + }, + message: "템플릿이 생성되었습니다.", + }); + } catch (error) { + 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: "템플릿을 찾을 수 없거나 시스템 템플릿입니다.", + }); + } + + res.json({ + success: true, + message: "템플릿이 삭제되었습니다.", + }); + } catch (error) { + 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..aa4bcb29 --- /dev/null +++ b/backend-node/src/routes/reportRoutes.ts @@ -0,0 +1,60 @@ +import { Router } from "express"; +import reportController from "../controllers/reportController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 리포트 API는 인증이 필요 +router.use(authenticateToken); + +// 템플릿 관련 라우트 (구체적인 경로를 먼저 배치) +router.get("/templates", (req, res, next) => + reportController.getTemplates(req, res, next) +); +router.post("/templates", (req, res, next) => + reportController.createTemplate(req, res, next) +); +router.delete("/templates/:templateId", (req, res, next) => + reportController.deleteTemplate(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.get("/:reportId/layout", (req, res, next) => + reportController.getLayout(req, res, next) +); +router.put("/:reportId/layout", (req, res, next) => + reportController.saveLayout(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..f6bf72d3 --- /dev/null +++ b/backend-node/src/services/reportService.ts @@ -0,0 +1,610 @@ +/** + * 리포트 관리 서비스 + */ + +import { v4 as uuidv4 } from "uuid"; +import { query, queryOne, transaction } from "../database/db"; +import { + ReportMaster, + ReportLayout, + ReportTemplate, + ReportDetail, + GetReportsParams, + GetReportsResponse, + CreateReportRequest, + UpdateReportRequest, + SaveLayoutRequest, + GetTemplatesResponse, + CreateTemplateRequest, +} from "../types/report"; + +export class ReportService { + /** + * 리포트 목록 조회 + */ + 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]); + + return { + report, + layout, + }; + } + + /** + * 리포트 생성 + */ + 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_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, + originalLayout.components, + 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) => { + // 기존 레이아웃 확인 + 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, + ]); + } + + return true; + }); + } + + /** + * 템플릿 목록 조회 + */ + 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; + } +} + +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..ab471298 --- /dev/null +++ b/backend-node/src/types/report.ts @@ -0,0 +1,129 @@ +/** + * 리포트 관리 시스템 타입 정의 + */ + +// 리포트 템플릿 +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 ReportDetail { + report: ReportMaster; + layout: ReportLayout | null; +} + +// 리포트 목록 조회 파라미터 +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[]; +} + +// 템플릿 목록 응답 +export interface GetTemplatesResponse { + system: ReportTemplate[]; + custom: ReportTemplate[]; +} + +// 템플릿 생성 요청 +export interface CreateTemplateRequest { + templateNameKor: string; + templateNameEng?: string; + templateType: string; + description?: string; + layoutConfig?: any; + defaultQueries?: any; +} + From 93e5331d6cbfe4af8032a4b57425b63b2fa9b588 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 1 Oct 2025 11:41:03 +0900 Subject: [PATCH 04/53] =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/admin/report/page.tsx | 111 ++++++++ .../components/report/ReportCreateModal.tsx | 228 ++++++++++++++++ .../components/report/ReportListTable.tsx | 250 ++++++++++++++++++ frontend/hooks/useReportList.ts | 63 +++++ frontend/lib/api/reportApi.ts | 119 +++++++++ frontend/types/report.ts | 156 +++++++++++ 6 files changed, 927 insertions(+) create mode 100644 frontend/app/(main)/admin/report/page.tsx create mode 100644 frontend/components/report/ReportCreateModal.tsx create mode 100644 frontend/components/report/ReportListTable.tsx create mode 100644 frontend/hooks/useReportList.ts create mode 100644 frontend/lib/api/reportApi.ts create mode 100644 frontend/types/report.ts diff --git a/frontend/app/(main)/admin/report/page.tsx b/frontend/app/(main)/admin/report/page.tsx new file mode 100644 index 00000000..11c3e89d --- /dev/null +++ b/frontend/app/(main)/admin/report/page.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useState } from "react"; +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 { ReportCreateModal } from "@/components/report/ReportCreateModal"; +import { Plus, Search, RotateCcw } from "lucide-react"; +import { useReportList } from "@/hooks/useReportList"; + +export default function ReportManagementPage() { + const [searchText, setSearchText] = useState(""); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + + const { reports, total, page, limit, isLoading, refetch, setPage, handleSearch } = useReportList(); + + const handleSearchClick = () => { + handleSearch(searchText); + }; + + const handleReset = () => { + setSearchText(""); + handleSearch(""); + }; + + const handleCreateSuccess = () => { + setIsCreateModalOpen(false); + refetch(); + }; + + return ( +
+
+ {/* 페이지 제목 */} +
+
+

리포트 관리

+

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

+
+ +
+ + {/* 검색 영역 */} + + + + + 검색 + + + +
+ setSearchText(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleSearchClick(); + } + }} + className="flex-1" + /> + + +
+
+
+ + {/* 리포트 목록 */} + + + + + 📋 리포트 목록 + (총 {total}건) + + + + + + + +
+ + {/* 리포트 생성 모달 */} + setIsCreateModalOpen(false)} + onSuccess={handleCreateSuccess} + /> +
+ ); +} diff --git a/frontend/components/report/ReportCreateModal.tsx b/frontend/components/report/ReportCreateModal.tsx new file mode 100644 index 00000000..56644632 --- /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: "", + 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: "", + reportType: "BASIC", + description: "", + }); + onClose(); + }; + + return ( + + + + 새 리포트 생성 + 새로운 리포트를 생성합니다. 필수 항목을 입력해주세요. + + +
+ {/* 리포트명 (한글) */} +
+ + setFormData({ ...formData, reportNameKor: e.target.value })} + /> +
+ + {/* 리포트명 (영문) */} +
+ + setFormData({ ...formData, reportNameEng: e.target.value })} + /> +
+ + {/* 템플릿 선택 */} +
+ + +
+ + {/* 리포트 타입 */} +
+ + +
+ + {/* 설명 */} +
+ +