diff --git a/.cursor/AGENT_SKILLS_MAP.md b/.cursor/AGENT_SKILLS_MAP.md new file mode 100644 index 00000000..ae3e7d03 --- /dev/null +++ b/.cursor/AGENT_SKILLS_MAP.md @@ -0,0 +1,85 @@ +# Cursor Agent & Skills 체계 매핑 + +## 최우선 제약: 리포트 기능 외 수정 금지 + +**모든 Agent와 Skill은 리포트 관련 파일만 수정한다.** + +### 허용 범위 + +``` +frontend/ +├── components/report/** # 리포트 컴포넌트 +├── app/(main)/admin/screenMng/reportList/** # 리포트 라우트 +├── contexts/ReportDesignerContext.tsx # 디자이너 상태 +├── hooks/useReportList.ts # 리포트 훅 +├── lib/api/reportApi.ts # 리포트 API +├── types/report.ts # 리포트 타입 +└── lib/registry/components/v2-report-viewer/** # 리포트 뷰어 V2 + +backend-node/src/ +├── routes/reportRoutes.ts +├── controllers/reportController.ts +├── services/reportService.ts +└── types/report.ts +``` + +### 범위 밖 파일에서 문제 발견 시 + +수정하지 말고 **보고만** 한다. 사용자 확인 후 진행. + +--- + +## 아키텍처 + +``` +.cursor/ +├── rules/ # 항상 적용 규칙 (8개, 자동 로드) +├── agents/ # 전문가 역할 (4개, 자동 위임) +├── skills/ # 워크플로우/지식 (12개, 필요 시 로드) +└── mcp.json +``` + +## Layer 1: Rules (항상 적용) + +| 파일 | 용도 | +|------|------| +| `api-client-usage.mdc` | fetch 금지, API 클라이언트 강제 | +| `database-guide.mdc` | PostgreSQL 쿼리 패턴 | +| `project-overview.mdc` | 기술 스택 개요 | +| `security-guide.mdc` | 인증/인가 | +| `multi-tenancy-guide.mdc` | company_code 필터링 | +| `admin-page-style-guide.mdc` | 관리자 페이지 스타일 (glob) | +| `modal-design.mdc` | 모달 디자인 (glob) | +| `component-development-guide.mdc` | V2 컴포넌트 상세 (요청 시) | + +## Layer 2: Agents (전문가, 격리 컨텍스트) + +| 에이전트 | 역할 | 자동 위임 | +|---------|------|----------| +| `code-reviewer` | 리포트 코드 품질/보안 검수 | Yes | +| `debugger` | 리포트 에러 진단/수정 | Yes | +| `pm` | 리포트 요구사항/명세서 | No | +| `web-verifier` | 리포트 UI 스크린샷 검증 | No | + +## Layer 3: Skills (워크플로우, 메인 컨텍스트) + +| Skill | 용도 | 자동 호출 | +|-------|------|----------| +| `implement` | 리포트 4단계 구현 워크플로우 | Yes | +| `plan` | 리포트 구현 계획서 + reportdocs 갱신 | Yes | +| `react-component` | 리포트 컴포넌트 클린코드 | Yes | +| `next-feature` | 리포트 Next.js 페이지/라우트 | Yes | +| `code-review` | 리포트 코드 검수 절차 | No | +| `code-fix` | 리포트 버그 수정 절차 | No | +| `github` | 리포트 변경 커밋 | No | +| `web-verify` | 리포트 UI 검증 절차 | No | +| `ui-debugging` | 리포트 UI 레이아웃/스크롤/스타일 | Yes | +| `component-registry` | 리포트 디자이너 컴포넌트 구조 | Yes | +| `table-sql` | 리포트 테이블 DDL/메타데이터 | Yes | +| `component-dev` | 리포트 V2 컴포넌트 개발 | Yes | +| `notion-writing` | Notion MCP 작성 규칙 (블록 제약, 서식, 사용자 스타일 가이드) | Yes | + +## 백업 + +- `cursor-rules-backup-20260309.tar.gz` (프로젝트 루트) +- 복원: `tar xzf cursor-rules-backup-20260309.tar.gz` diff --git a/.cursor/agents/code-reviewer.md b/.cursor/agents/code-reviewer.md new file mode 100644 index 00000000..c9a4711e --- /dev/null +++ b/.cursor/agents/code-reviewer.md @@ -0,0 +1,54 @@ +--- +name: code-reviewer +description: WACE PLM 코드 리뷰 전문가. 코드 변경 후 품질, 보안, 멀티테넌시를 검수. 코드 리뷰 요청 시 즉시 사용. Use proactively after code modifications. +--- + +## 수정 범위 제약 (최우선) + +**리포트 관련 파일만 수정 허용. 그 외 파일은 절대 수정하지 않는다.** + +허용 범위: +- `frontend/components/report/**` +- `frontend/app/(main)/admin/screenMng/reportList/**` +- `frontend/contexts/ReportDesignerContext.tsx` +- `frontend/hooks/useReportList.ts` +- `frontend/lib/api/reportApi.ts` +- `frontend/types/report.ts` +- `backend-node/src/routes/reportRoutes.ts` +- `backend-node/src/controllers/reportController.ts` +- `backend-node/src/services/reportService.ts` +- `backend-node/src/types/report.ts` + +리뷰 중 허용 범위 밖 파일에서 문제를 발견하면 **수정하지 말고 보고만** 한다. + +## 리뷰 절차 + +1. git diff로 최근 변경 확인 +2. 변경된 파일이 허용 범위 내인지 확인 +3. 체크리스트 기반 검수 +4. 우선순위별 피드백 제공 + +## 필수 검수 체크리스트 + +### 보안 / 멀티테넌시 +- [ ] SELECT/INSERT/UPDATE/DELETE에 `company_code` 필터링 적용 +- [ ] JOIN 쿼리의 ON 절에 `company_code` 매칭 +- [ ] `req.user.companyCode` 사용 (클라이언트 입력 금지) + +### API / 프론트엔드 +- [ ] `fetch` 직접 사용 금지 → `lib/api/reportApi.ts` 사용 +- [ ] shadcn/ui 스타일 가이드 준수 +- [ ] CSS 변수 사용 (하드코딩 색상 금지) + +### 클린코드 +- [ ] 500줄 초과 컴포넌트 없음 +- [ ] `any` 타입 남용 없음 +- [ ] 사용하지 않는 import 없음 +- [ ] interface props가 실제 전달과 일치 + +## 피드백 형식 + +- **치명적**: 반드시 수정 (보안, 빌드 실패) +- **경고**: 수정 권장 (성능, 유지보수성) +- **제안**: 선택적 개선 +- **범위 밖 발견**: 리포트 외 파일 문제 (수정 금지, 보고만) diff --git a/.cursor/agents/debugger.md b/.cursor/agents/debugger.md new file mode 100644 index 00000000..cb9ccd78 --- /dev/null +++ b/.cursor/agents/debugger.md @@ -0,0 +1,51 @@ +--- +name: debugger +description: WACE PLM 디버깅 전문가. 에러, 테스트 실패, 예상치 못한 동작을 체계적으로 진단하고 수정. 오류 발생 시 자동 사용. Use proactively when encountering any issues. +--- + +## 수정 범위 제약 (최우선) + +**리포트 관련 파일만 수정 허용. 그 외 파일은 절대 수정하지 않는다.** + +허용 범위: +- `frontend/components/report/**` +- `frontend/app/(main)/admin/screenMng/reportList/**` +- `frontend/contexts/ReportDesignerContext.tsx` +- `frontend/hooks/useReportList.ts` +- `frontend/lib/api/reportApi.ts` +- `frontend/types/report.ts` +- `backend-node/src/routes/reportRoutes.ts` +- `backend-node/src/controllers/reportController.ts` +- `backend-node/src/services/reportService.ts` +- `backend-node/src/types/report.ts` + +에러 원인이 허용 범위 밖 파일에 있으면 **수정하지 말고 원인만 보고**한다. + +## 진단 절차 + +1. 에러 메시지와 스택 트레이스 캡처 +2. 에러 발생 파일이 허용 범위 내인지 확인 +3. 실패 위치 격리 +4. 허용 범위 내에서 최소한의 수정 구현 +5. 수정 검증 + +## 프로젝트 특화 디버깅 포인트 + +### 리포트 프론트엔드 +- ReportDesignerContext 상태 관리 문제 +- 디자이너 컴포넌트 간 props 불일치 +- 리포트 프리뷰 렌더링 오류 +- API 클라이언트 환경별 URL 문제 + +### 리포트 백엔드 +- reportService PostgreSQL 쿼리 오류 +- company_code 필터링 누락 +- 리포트 데이터 직렬화/역직렬화 오류 + +## 출력 형식 + +각 이슈에 대해: +- 근본 원인 설명 +- 수정 파일이 허용 범위 내인지 명시 +- 구체적 코드 수정 (허용 범위 내만) +- 테스트 방법 diff --git a/.cursor/agents/pm.md b/.cursor/agents/pm.md new file mode 100644 index 00000000..2ea6fe6e --- /dev/null +++ b/.cursor/agents/pm.md @@ -0,0 +1,58 @@ +--- +name: pm +description: WACE PLM 리포트 프로젝트 매니저. 리포트 기능 요구사항 분석, 명세서 작성, 작업 분해를 담당. 기능 기획이나 요구사항 정리가 필요할 때 사용. +--- + +## 수정 범위 제약 (최우선) + +**리포트 관련 기능만 기획/분석한다. 그 외 기능은 범위 밖이다.** + +현재 프로젝트 범위: +- Phase 1: 리포트 관리 페이지 + 디자이너 고도화 +- Phase 2: 내부 리포트 목록 (컨텍스트 뷰어) +- Phase 3: 화면관리 컴포넌트화 (리포트 컴포넌트 삽입) + +## 필수 참조 문서 (작업 전) + +1. `reportdocs/STATUS.md` → 현재 진행 상태 +2. `reportdocs/PLAN.md` → 구현 계획 +3. `reportdocs/ARCHITECTURE.md` → 코드 구조 +4. `reportdocs/INDEX.md` → 기능별 파일 색인 + +## Notion 작성 규칙 + +Notion 페이지 생성/작성 시 반드시 `.cursor/skills/notion-writing/SKILL.md`를 참조한다. +- WACE 페이지 하위에 저장 +- paragraph, bulleted_list_item만 사용 (heading, divider, code 블록 불가) +- 마크다운 문법(##, ---, ```) 텍스트에 넣지 않음 +- bold/code annotation으로 서식 적용 + +## 역할 + +1. 리포트 기능 요구사항 분석 및 구조화 +2. 기능 명세서 작성 +3. 작업 분해 (WBS) +4. reportdocs/ 갱신 + +## 명세서 작성 형식 + +```markdown +# [리포트 기능명] 명세서 + +## 개요 +[기능 설명 1-2문장] + +## 요구사항 +- FR-1: ... + +## 영향 범위 +- 프론트엔드: components/report/ 내 파일 +- 백엔드: reportRoutes/reportController/reportService +- DB: report_master, report_details 등 + +## 작업 분해 +1. [ ] 작업 1 (예상: Xh) + +## 리스크 +- ... +``` diff --git a/.cursor/agents/web-verifier.md b/.cursor/agents/web-verifier.md new file mode 100644 index 00000000..7a0f4963 --- /dev/null +++ b/.cursor/agents/web-verifier.md @@ -0,0 +1,37 @@ +--- +name: web-verifier +description: WACE PLM UI 검증 전문가. 로컬 서버에 자동 로그인 후 스크린샷으로 UI 변경사항을 검증. 화면 구현 후 시각적 확인이 필요할 때 사용. +--- + +## 검증 절차 +1. 문제 상황 분석 및 로컬서버 상태 확인 (프론트엔드: 9771, 백엔드: 9090) +2. 브라우저로 로그인 페이지 접속 +3. 아래 계정으로 자동 로그인 +4. 요청된 화면으로 이동으로 이동하고, 스크린샷 캡처 및 분석 +5. 요청된 문제 상황과 현재의 화면 구성 비교하고, 요구된 내용으로 수정 +6. 요구된 내용으로 수정이 되었는지 일한 페이지에서 다시 스크린샷 캡처 및 분석 +7. 결과 정리 및 반환 + +## 로그인 정보 (자동 적용) + +- URL: http://localhost:9771 +- 아이디: wace +- 비밀번호: qlalfqjsgh11 + +## 검증 체크리스트 + +### 리포트 목록 +- [ ] 테이블 데이터 정상 로딩 +- [ ] 생성/수정/삭제 버튼 동작 +- [ ] 검색/필터 동작 + +### 리포트 디자이너 +- [ ] 캔버스 렌더링 +- [ ] 컴포넌트 드래그&드롭 +- [ ] 속성 패널 동작 +- [ ] 프리뷰 모달 + +### 공통 +- [ ] 스크롤 정상 작동 +- [ ] 중첩 박스 없음 +- [ ] 콘솔 에러 없음 diff --git a/.cursor/plans/large-file-refactoring-plan.md b/.cursor/plans/large-file-refactoring-plan.md new file mode 100644 index 00000000..f1560193 --- /dev/null +++ b/.cursor/plans/large-file-refactoring-plan.md @@ -0,0 +1,374 @@ +# 대규모 파일 모듈 분리 리팩토링 계획 + +> 작성일: 2026-03-10 +> 대상: dohyeons 작성 코드 중 대규모 파일 7개 + +--- + +## 전체 현황 + +| # | 파일 | 줄 수 | 외부 소비자 수 | 분리 난이도 | +|---|------|-------|---------------|------------| +| 1 | `frontend/lib/utils/buttonActions.ts` | 7,835 | 3곳 | 중 | +| 2 | `frontend/components/screen/ScreenDesigner.tsx` | 7,572 | 2곳 | 상 | +| 3 | `frontend/lib/registry/components/table-list/TableListComponent.tsx` | 6,815 | 3곳 | 상 | +| 4 | `backend-node/src/services/screenManagementService.ts` | 6,614 | **1곳** | **하** | +| 5 | `backend-node/src/services/tableManagementService.ts` | 5,346 | 3곳 | 중 | +| 6 | `frontend/components/screen/ScreenSettingModal.tsx` | 5,108 | **1곳** | **하** | +| 7 | `frontend/components/screen/config-panels/ButtonConfigPanel.tsx` | 4,693 | 5곳 | 상 | + +--- + +## 1. `buttonActions.ts` (7,835줄) + +### 현재 구조 +단일 `ButtonActionExecutor` 클래스에 20+ 핸들러 메서드가 모두 포함. + +### 외부에서 사용하는 곳 + +| 소비자 파일 | 가져오는 심볼 | +|------------|-------------| +| `lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | `ButtonActionExecutor`, `ButtonActionContext`, `ButtonActionType`, `DEFAULT_BUTTON_ACTIONS` | +| `lib/registry/components/button-primary/ButtonPrimaryComponent.tsx` | `ButtonActionExecutor`, `ButtonActionContext`, `ButtonActionType`, `DEFAULT_BUTTON_ACTIONS` | +| `lib/registry/components/v2-button-primary/types.ts` | `ButtonActionConfig` | +| `lib/registry/components/button-primary/types.ts` | `ButtonActionConfig` | +| `components/screen/EditModal.tsx` | `ButtonActionExecutor` (동적 import) | + +### 외부에서 호출하는 메서드 (2개만) +- `executeAction()` ← ButtonPrimaryComponent에서 호출 +- `executeAfterSaveControl()` ← EditModal에서 호출 + +### 나머지 20+ 핸들러는 모두 내부 전용 +`handleSave`, `handleDelete`, `handleModal`, `handleControl`, `handleExcelDownload` 등은 `executeAction` 내부에서만 분기 호출됨. + +### 분리 계획 + +``` +frontend/lib/utils/buttonActions/ +├── index.ts # 기존 export 유지 (호환성) +├── types.ts # ButtonActionType, ButtonActionConfig, ButtonActionContext (~300줄) +├── utils.ts # normalizeFormDataArrays, resolveSpecialKeyword (~50줄) +├── defaults.ts # DEFAULT_BUTTON_ACTIONS (~130줄) +├── ButtonActionExecutor.ts # executeAction 라우터 + 공통 메서드 (~500줄) +└── handlers/ + ├── saveHandler.ts # handleSave, handleSubmit, handleBatchSave (~700줄) + ├── deleteHandler.ts # handleDelete (~130줄) + ├── modalHandler.ts # handleModal, handleOpenRelatedModal (~500줄) + ├── editHandler.ts # handleEdit, handleCopy (~370줄) + ├── controlHandler.ts # handleControl (~850줄) + ├── excelHandler.ts # handleExcelDownload/Upload (~600줄) + ├── trackingHandler.ts # handleTrackingStart/Stop (~500줄) + ├── dataHandler.ts # handleTransferData, handleSwapFields, handleQuickInsert (~500줄) + ├── operationHandler.ts # handleOperationControl (~320줄) + ├── specialHandler.ts # handleBarcodeScan, handleCodeMerge, handleEvent (~300줄) + └── rackHandler.ts # handleRackStructureBatchSave 등 (~400줄) +``` + +### 영향 범위 +- `index.ts`에서 기존 심볼 re-export → **외부 코드 변경 0건** +- 내부 핸들러 분리는 외부에 영향 없음 + +--- + +## 2. `ScreenDesigner.tsx` (7,572줄) + +### 현재 구조 +상태 50+개, 이벤트 핸들러 30+개, JSX 1,700줄이 단일 함수 컴포넌트에 포함. + +### 외부에서 사용하는 곳 (2곳만) + +| 소비자 파일 | 전달 props | +|------------|-----------| +| `app/(main)/admin/screenMng/screenMngList/page.tsx` | `selectedScreen`, `onBackToList`, `onScreenUpdate` | +| `components/screen/ScreenSettingModal.tsx` | `selectedScreen`, `onBackToList` | + +### Props 인터페이스 +```typescript +interface ScreenDesignerProps { + selectedScreen: ScreenDefinition | null; + onBackToList: () => void; + onScreenUpdate?: (updatedScreen: Partial) => void; + isPop?: boolean; + defaultDevicePreview?: "mobile" | "tablet"; +} +``` + +### ScreenDesigner가 의존하는 것들 + +| 카테고리 | 주요 의존성 | +|---------|-----------| +| UI 컴포넌트 (15+) | `SlimToolbar`, `ComponentsPanel`, `PropertiesPanel`, `LayerManagerPanel`, `FlowButtonGroup`, `FlowButtonGroupDialog`, `MenuAssignmentModal` 등 | +| 유틸 (10+) | `gridUtils`, `alignmentUtils`, `groupingUtils`, `flowButtonGroupUtils`, `webTypeMapping`, `layoutV2Converter` 등 | +| API (4) | `screenApi`, `tableTypeApi`, `tableManagementApi`, `ExternalRestApiConnectionAPI` | +| 타입 (10+) | `ScreenDefinition`, `ComponentData`, `LayoutData`, `GridSettings` 등 | +| Context (3) | `LayerProvider`, `ScreenPreviewProvider`, `TableOptionsProvider` | + +### 분리 계획 + +``` +frontend/components/screen/screen-designer/ +├── index.ts # export default ScreenDesigner (호환성) +├── ScreenDesigner.tsx # 메인 (조합만, ~800줄) +├── types.ts # ScreenDesignerProps +├── constants.ts # panelConfigs +│ +├── hooks/ +│ ├── useDesignerState.ts # 50+ useState 묶음 (~200줄) +│ ├── useLayoutLoader.ts # loadLayout, loadScreenDataSource (~400줄) +│ ├── useLayoutHistory.ts # saveToHistory, undo, redo (~150줄) +│ ├── useComponentProperty.ts # updateComponentProperty (~300줄) +│ ├── usePanZoom.ts # Pan/Zoom/Grid (~250줄) +│ ├── useDesignerKeyboard.ts # 키보드 단축키 (~500줄) +│ ├── useAlignmentHandlers.ts # 정렬/배분/크기맞춤 (~200줄) +│ ├── useClipboard.ts # copy, paste, delete (~400줄) +│ ├── useDragHandlers.ts # startDrag, updateDrag, endDrag (~400줄) +│ └── useDropHandlers.ts # handleDrop 통합 (~1,000줄) +│ +├── components/ +│ ├── DesignerCanvas.tsx # 캔버스 영역 (~400줄) +│ ├── DesignerPropertiesPanel.tsx # 속성 패널 분기 (~600줄) +│ ├── FlowButtonGroupPanel.tsx # 플로우 버튼 그룹 UI (~200줄) +│ ├── ActiveLayerIndicator.tsx # 레이어 인디케이터 (~50줄) +│ └── DesignerModals.tsx # 모달 묶음 (~200줄) +│ +└── utils/ + ├── gridSnapUtils.ts # snapTo10px, calculateGridInfo (~50줄) + ├── webTypeDefaults.ts # getDefaultWebTypeConfig (~50줄) + └── fileComponentRestore.ts # restoreFileComponentsData (~100줄) +``` + +### 영향 범위 +- `screen-designer/index.ts`에서 `export default ScreenDesigner` → **외부 import 변경 필요** +- 변경 대상: `screenMngList/page.tsx`, `ScreenSettingModal.tsx` (2곳만) +- 또는 기존 `ScreenDesigner.tsx`를 re-export 래퍼로 남겨두면 **변경 0건** + +--- + +## 3. `TableListComponent.tsx` (6,815줄) + +### 현재 구조 +데이터 fetch, 필터링, 인라인 편집, WebSocket, Excel/PDF 내보내기가 모두 한 컴포넌트에 포함. + +### 외부에서 사용하는 곳 + +| 소비자 파일 | 가져오는 심볼 | +|------------|-------------| +| `table-list/TableListRenderer.tsx` | `TableListComponent` | +| `table-list/index.ts` | `TableListWrapper` | +| `components/v2/V2List.tsx` | `TableListComponent` | + +### 분리 계획 + +``` +frontend/lib/registry/components/table-list/ +├── TableListComponent.tsx # 메인 (조합, ~800줄) +├── types.ts # 인터페이스, 상수 (~200줄) +│ +├── hooks/ +│ ├── useTableData.ts # fetch, 페이지네이션, 정렬 (~500줄) +│ ├── useTableFilters.ts # 헤더 필터, 고급 검색 (~400줄) +│ ├── useTableSelection.ts # 행 선택 (~200줄) +│ ├── useTableEditing.ts # 인라인 편집 (~300줄) +│ ├── useTableState.ts # 컬럼 순서/너비 저장 (~200줄) +│ ├── useTableWebSocket.ts # WebSocket (~150줄) +│ └── useTableExport.ts # Excel/PDF (~200줄) +│ +├── components/ +│ ├── TableHeader.tsx # 헤더 렌더링 (~300줄) +│ ├── TableBody.tsx # 바디 렌더링 (~400줄) +│ ├── TableContextMenu.tsx # 우클릭 메뉴 (~150줄) +│ ├── FilterPanel.tsx # 필터 패널 (~200줄) +│ └── ColumnOptionsPanel.tsx # 컬럼 옵션 (~200줄) +│ +└── utils/ + ├── formatCellValue.ts # 셀 값 포맷팅 (~100줄) + └── filterUtils.ts # 필터 조건 평가 (~100줄) +``` + +### 영향 범위 +- `TableListComponent`, `TableListWrapper` export 유지 → **외부 변경 0건** + +--- + +## 4. `screenManagementService.ts` (6,614줄) - 소비자 1곳 + +### 외부에서 사용하는 곳 + +| 소비자 파일 | 가져오는 심볼 | +|------------|-------------| +| `controllers/screenManagementController.ts` | `screenManagementService` (싱글톤 인스턴스) | + +### 분리 계획 + +``` +backend-node/src/services/screen/ +├── index.ts # screenManagementService 싱글톤 re-export +├── ScreenManagementService.ts # 클래스 정의 + 메서드 위임 (~300줄) +├── screenCrudService.ts # createScreen, getScreen*, updateScreen* (~600줄) +├── screenDeletionService.ts # delete, restore, permanentDelete, bulk* (~800줄) +├── screenLayoutService.ts # saveLayout, getLayout (~600줄) +├── screenMenuService.ts # assignScreenToMenu, getScreensByMenu (~300줄) +├── screenTemplateService.ts # getTemplatesByCompany, createTemplate (~200줄) +├── screenColumnService.ts # getColumnInfo, setColumnWebType, generateWidget (~400줄) +├── screenCodeGenerator.ts # generateScreenCode (~200줄) +└── screenTableService.ts # getTables, getTableInfo, getTableColumns (~400줄) +``` + +### 영향 범위 +- `index.ts`에서 `screenManagementService` re-export → **컨트롤러 변경 0건** +- **소비자 1곳** → 가장 안전한 리팩토링 대상 + +--- + +## 5. `tableManagementService.ts` (5,346줄) + +### 외부에서 사용하는 곳 + +| 소비자 파일 | 가져오는 심볼 | +|------------|-------------| +| `controllers/tableManagementController.ts` | `TableManagementService` | +| `controllers/entityJoinController.ts` | `TableManagementService` | +| `services/multiConnectionQueryService.ts` | `TableManagementService` | + +### 분리 계획 + +``` +backend-node/src/services/table/ +├── index.ts # TableManagementService re-export +├── TableManagementService.ts # 클래스 정의 + 메서드 위임 (~300줄) +├── tableMasterService.ts # getTableList, getTableLabels (~400줄) +├── columnSettingsService.ts # getColumnList, updateColumnSettings (~800줄) +├── tableDataService.ts # getTableData, addTableData, editTableData, deleteTableData (~800줄) +├── tableEntityJoinService.ts # getTableDataWithEntityJoins (~1,000줄) +├── tableValidationService.ts # validateNotNull, validateUnique (~200줄) +├── tableLogService.ts # createLogTable, getLogConfig, toggleLogTable (~400줄) +└── tableSchemaService.ts # getTableSchema, checkTableExists (~400줄) +``` + +### 영향 범위 +- `index.ts`에서 `TableManagementService` re-export → **외부 변경 0건** + +--- + +## 6. `ScreenSettingModal.tsx` (5,108줄) - 소비자 1곳 + +### 현재 구조 +4개 탭 컴포넌트(`OverviewTab`, `FieldMappingTab`, `DataFlowTab`, `ControlManagementTab`)와 서브 컴포넌트(`SearchableSelect`, `TableColumnAccordion`, `JoinSettingEditor`)가 모두 인라인 정의. + +### 외부에서 사용하는 곳 + +| 소비자 파일 | 가져오는 심볼 | +|------------|-------------| +| `components/screen/ScreenRelationFlow.tsx` | `ScreenSettingModal` | + +### 분리 계획 + +``` +frontend/components/screen/screen-setting/ +├── index.ts # ScreenSettingModal re-export +├── ScreenSettingModal.tsx # 메인 모달 셸 (~500줄) +├── hooks/ +│ └── useScreenSettingData.ts # loadData, dataFlows (~300줄) +├── components/ +│ ├── SearchableSelect.tsx # 검색 가능 셀렉트 (~60줄) +│ ├── TableColumnAccordion.tsx # 컬럼 아코디언 (~500줄) +│ └── JoinSettingEditor.tsx # 조인 설정 에디터 (~200줄) +└── tabs/ + ├── OverviewTab.tsx # 개요 탭 (~900줄) + ├── FieldMappingTab.tsx # 필드 매핑 탭 (~400줄) + ├── DataFlowTab.tsx # 데이터 플로우 탭 (~300줄) + └── ControlManagementTab.tsx # 버튼 제어 탭 (~2,000줄) +``` + +### 영향 범위 +- `index.ts`에서 re-export → **외부 변경 0건** +- **소비자 1곳** → 안전한 리팩토링 대상 + +--- + +## 7. `ButtonConfigPanel.tsx` (4,693줄) - 소비자 5곳 + +### 현재 구조 +액션 타입별 설정 UI가 조건부 렌더링으로 3,200줄, 인라인 엑셀 설정 컴포넌트 4개가 포함. + +### 외부에서 사용하는 곳 + +| 소비자 파일 | 가져오는 심볼 | import 방식 | +|------------|-------------|------------| +| `lib/registry/init.ts` | `ButtonConfigPanel` | 정적 import | +| `lib/utils/getConfigPanelComponent.tsx` | `OriginalButtonConfigPanel` | 정적 import (별칭) | +| `lib/utils/getComponentConfigPanel.tsx` | `ButtonConfigPanel` | 동적 import | +| `components/screen/panels/V2PropertiesPanel.tsx` | `ButtonConfigPanel` | 정적 import | +| `components/screen/panels/DetailSettingsPanel.tsx` | `NewButtonConfigPanel` | 정적 import (별칭) | + +### 분리 계획 + +``` +frontend/components/screen/config-panels/button-config/ +├── index.ts # ButtonConfigPanel re-export +├── ButtonConfigPanel.tsx # 메인 (액션 타입별 라우팅, ~500줄) +├── hooks/ +│ ├── useButtonConfigState.ts # 50+ useState (~200줄) +│ └── useButtonConfigData.ts # loadTableColumns, filterScreens (~300줄) +├── sections/ +│ ├── SaveActionConfig.tsx # 저장 액션 설정 (~400줄) +│ ├── ModalActionConfig.tsx # 모달 액션 설정 (~400줄) +│ ├── NavigateActionConfig.tsx # 네비게이션 설정 (~300줄) +│ ├── EditActionConfig.tsx # 편집 설정 (~200줄) +│ ├── ControlActionConfig.tsx # 제어 설정 (~300줄) +│ └── ExcelActionConfig.tsx # 엑셀 설정 (~500줄) +└── components/ + ├── MasterDetailExcelUploadConfig.tsx (~340줄) + ├── ExcelNumberingRuleInfo.tsx (~15줄) + ├── ExcelAfterUploadControlConfig.tsx (~155줄) + └── ExcelUploadConfigSection.tsx (~160줄) +``` + +### 영향 범위 +- `button-config/index.ts`에서 `ButtonConfigPanel` re-export +- 기존 `ButtonConfigPanel.tsx` 파일 경로가 바뀌므로 **5곳 import 경로 수정 필요** +- 또는 기존 위치에 re-export 래퍼를 남겨두면 **변경 0건** + +--- + +## 선택적 실행 가이드 + +### 안전도 기준 (소비자 수 기반) + +| 안전도 | 파일 | 소비자 | 비고 | +|--------|------|--------|------| +| **매우 안전** | `screenManagementService.ts` | 1곳 | 컨트롤러 1개만 사용 | +| **매우 안전** | `ScreenSettingModal.tsx` | 1곳 | ScreenRelationFlow만 사용 | +| **안전** | `ScreenDesigner.tsx` | 2곳 | 페이지 1 + 모달 1 | +| **안전** | `buttonActions.ts` | 3곳 | 버튼 컴포넌트 2 + EditModal 1 | +| **안전** | `TableListComponent.tsx` | 3곳 | 렌더러 + index + V2List | +| **안전** | `tableManagementService.ts` | 3곳 | 컨트롤러 2 + 서비스 1 | +| **주의** | `ButtonConfigPanel.tsx` | 5곳 | 정적 3 + 동적 1 + 별칭 1 | + +### 독립 실행 가능 여부 + +각 파일은 **서로 의존하지 않으므로** 원하는 것만 선택적으로 진행 가능합니다. + +| 파일 | 다른 대상 파일과의 의존성 | 독립 실행 | +|------|------------------------|----------| +| `buttonActions.ts` | 없음 | 가능 | +| `ScreenDesigner.tsx` | 없음 | 가능 | +| `TableListComponent.tsx` | 없음 | 가능 | +| `screenManagementService.ts` | 없음 | 가능 | +| `tableManagementService.ts` | 없음 | 가능 | +| `ScreenSettingModal.tsx` | ScreenDesigner를 import하지만 분리와 무관 | 가능 | +| `ButtonConfigPanel.tsx` | 없음 | 가능 | + +--- + +## 권장 실행 순서 (효과 대비 안전도) + +| 순서 | 파일 | 이유 | +|------|------|------| +| 1 | `screenManagementService.ts` | 소비자 1곳, 백엔드라 UI 영향 없음 | +| 2 | `ScreenSettingModal.tsx` | 소비자 1곳, 탭 분리라 구조 명확 | +| 3 | `buttonActions.ts` | 핸들러별 분리라 패턴 명확, 외부 변경 0건 | +| 4 | `ScreenDesigner.tsx` | 가장 효과 큼 (7,572줄), 소비자 2곳 | +| 5 | `tableManagementService.ts` | 백엔드, 패턴이 #1과 동일 | +| 6 | `TableListComponent.tsx` | 훅 추출 복잡도 높음 | +| 7 | `ButtonConfigPanel.tsx` | 소비자 5곳, import 경로 관리 필요 | diff --git a/.cursor/plans/리포트_컴포넌트화_Phase3_확장_계획서.md b/.cursor/plans/리포트_컴포넌트화_Phase3_확장_계획서.md new file mode 100644 index 00000000..617e1532 --- /dev/null +++ b/.cursor/plans/리포트_컴포넌트화_Phase3_확장_계획서.md @@ -0,0 +1,568 @@ +# 리포트 컴포넌트화 (Phase 3 확장) — 실행 계획서 + +> **작성일**: 2026-03-10 | **최종 업데이트**: 2026-03-10 (v5) +> **목표**: 리포트 디자이너에서 만든 리포트를 화면관리의 V2 컴포넌트(`v2-report-viewer`)에서 **reportId를 직접 지정**하여 배치하고, 인라인 또는 모달로 렌더링하는 것. + +### 참조 문서 + +| 순서 | 문서 | 경로 | 반영 내용 | +|------|------|------|----------| +| 1 | V2 컴포넌트 분석 가이드 | `docs/V2_컴포넌트_분석_가이드.md` | 파일 구조, `v2-` 접두사, Definition 네이밍 | +| 2 | V2 컴포넌트 연동 가이드 | `docs/V2_컴포넌트_연동_가이드.md` | ScreenContext, V2 이벤트 시스템, formData 공유 | +| 3 | 화면개발 표준 가이드 | `docs/screen-implementation-guide/화면개발_표준_가이드.md` | V2 컴포넌트 목록, screen_layouts_v2 저장 구조 | +| 4 | CLAUDE.md | `CLAUDE.md` | 네이밍 규칙, 표준 파일 구조, 코딩 규칙 | + +--- + +## 완성 후 동작 플로우 + +### 플로우 A: 관리자가 화면에 리포트를 배치하는 과정 (설정 시점) + +``` +[1] 관리자가 화면 디자이너에 접속 + URL: /admin/screenMng/screenMngList + → 화면 목록에서 "견적 관리" 화면을 더블클릭 + → 화면 디자이너 진입 (URL 변화 없음, ScreenDesigner.tsx 렌더링) + +[2] 좌측 컴포넌트 패널에서 "리포트 뷰어" (v2-report-viewer)를 캔버스에 드래그&드롭 + ┌──────────────────────────────────────────────────────────────────┐ + │ [좌측 패널] [캔버스] [우측 패널] │ + │ │ + │ ▸ 입력 ┌────────────────────────┐ │ + │ ▸ 버튼 │ v2-input: 주문번호 │ │ + │ ▸ 테이블 ├────────────────────────┤ │ + │ ▸ 표시 │ v2-table-list │ │ + │ ├ 리포트 뷰어 ◀ │ (주문 목록 테이블) │ │ + │ ├ 텍스트 ├────────────────────────┤ │ + │ └ ... │ ┌──────────────────┐ │ │ + │ │ │ 📄 리포트 (뷰어) │ │ ← 방금 배치 │ + │ │ │ │ │ │ + │ │ └──────────────────┘ │ │ + │ └────────────────────────┘ │ + └──────────────────────────────────────────────────────────────────┘ + +[3] 배치한 리포트 뷰어 컴포넌트를 클릭 → 우측 설정 패널이 열림 + ┌─────────────────────────────────────────────┐ + │ 리포트 뷰어 설정 │ + │ │ + │ 컴포넌트 제목 │ + │ [견적서 미리보기] ___________________ │ + │ │ + │ 리포트 선택 │ + │ ┌─────────────────────────────────────┐ │ + │ │ 리포트를 선택해주세요 [선택] │ │ + │ └─────────────────────────────────────┘ │ + │ │ + │ 표시 모드 │ + │ [모달 (클릭 시 팝업) ▼] │ + │ │ + │ 파라미터 매핑 │ + │ 매핑 없음 — 폼 데이터가 자동 주입됩니다 │ + │ [+ 추가] │ + └─────────────────────────────────────────────┘ + +[4] "선택" 버튼 클릭 → 리포트 선택 모달이 열림 + ┌──────────────────────────────────────────────────────┐ + │ 리포트 선택 [×] │ + │ ┌──────────────────────────────────────────────┐ │ + │ │ 🔍 견적... │ │ + │ └──────────────────────────────────────────────┘ │ + │ │ + │ ┌──────┬──────────────┬──────────┬─────────────┐ │ + │ │ ID │ 리포트명 │ 유형 │ 사용여부 │ │ + │ ├──────┼──────────────┼──────────┼─────────────┤ │ + │ │ 12 │ ▶ 견적서 ◀ │ 견적 │ Y │ ← 클릭! + │ │ 15 │ 발주서 │ 발주 │ Y │ │ + │ │ 18 │ 검수 보고서 │ 검사 │ Y │ │ + │ └──────┴──────────────┴──────────┴─────────────┘ │ + │ │ + │ * 리포트 관리에서 만든 리포트 목록이 표시됩니다 │ + │ * 리포트 관리 URL: /admin/screenMng/reportList │ + └──────────────────────────────────────────────────────┘ + +[5] "견적서" 행 클릭 → 모달 닫히고 설정 패널에 선택 결과 표시 + ┌─────────────────────────────────────────────┐ + │ 리포트 뷰어 설정 │ + │ │ + │ 컴포넌트 제목 │ + │ [견적서 미리보기] ___________________ │ + │ │ + │ 리포트 선택 │ + │ ┌─────────────────────────────────────┐ │ + │ │ 📄 견적서 [변경] [×] │ │ + │ │ ID: 12 | 유형: 견적 │ │ + │ └─────────────────────────────────────┘ │ + │ │ + │ 표시 모드 │ + │ [인라인 (화면 내 직접 표시) ▼] │ ← "인라인"으로 변경 + │ │ + │ 파라미터 매핑 │ + │ ┌─────────────────────────────────────┐ │ + │ │ $1 ← order_no [×] │ │ ← 매핑 추가 + │ │ [+ 추가] │ │ + │ └─────────────────────────────────────┘ │ + │ 쿼리의 $1에 폼 데이터의 order_no 값 전달 │ + └─────────────────────────────────────────────┘ + +[6] 화면 저장 → screen_layouts_v2 테이블에 JSONB로 저장됨 + 저장되는 componentConfig: + { + "title": "견적서 미리보기", + "reportId": 12, + "reportName": "견적서", + "displayMode": "inline", + "paramMappings": [{ "param": "$1", "formField": "order_no" }] + } +``` + +--- + +### 플로우 B: 사용자가 화면에서 리포트를 보는 과정 (실행 시점 — 인라인 모드) + +``` +[1] 사용자가 "견적 관리" 화면에 접속 + URL: /screens/45?menuObjid=789 + → DynamicComponentRenderer가 screen_layouts_v2에서 레이아웃 로드 + → v2-report-viewer 컴포넌트 렌더링 시작 + +[2] 화면 초기 상태 (formData에 order_no 없음) + ┌──────────────────────────────────────────────────────────────┐ + │ 견적 관리 │ + │ ┌────────────────────────────────────────────────────────┐ │ + │ │ 주문번호: [_______________] [조회] │ │ + │ ├────────────────────────────────────────────────────────┤ │ + │ │ 주문 목록 테이블 │ │ + │ │ ┌──────┬──────────┬──────────┬──────────┐ │ │ + │ │ │ 번호 │ 주문번호 │ 고객명 │ 금액 │ │ │ + │ │ │ │ │ │ │ │ │ + │ │ │ (데이터 없음) │ │ │ + │ │ └──────┴──────────┴──────────┴──────────┘ │ │ + │ ├────────────────────────────────────────────────────────┤ │ + │ │ 견적서 미리보기 [↻ 새로고침] [↗ 전체보기] │ │ + │ │ ┌──────────────────────────────────────────────────┐ │ │ + │ │ │ │ │ │ + │ │ │ 파라미터가 없어 리포트를 표시할 수 없습니다 │ │ │ + │ │ │ │ │ │ + │ │ └──────────────────────────────────────────────────┘ │ │ + │ └────────────────────────────────────────────────────────┘ │ + └──────────────────────────────────────────────────────────────┘ + +[3] 사용자가 주문번호 입력 후 조회 → 테이블에 데이터 표시 → 행 선택 + → formData가 변경됨: { order_no: "ORD-2026-001", ... } + → v2-report-viewer가 ScreenContext.formData 변경 감지 + → buildContextParams: { "$1": "ORD-2026-001" } + → useReportExecution 훅이 즉시 쿼리 실행 + → reportApi.executeQuery(12, queryId, { "$1": "ORD-2026-001" }) + +[4] 리포트가 인라인으로 렌더링됨 (축소 표시) + ┌──────────────────────────────────────────────────────────────┐ + │ 견적 관리 │ + │ ┌────────────────────────────────────────────────────────┐ │ + │ │ 주문번호: [ORD-2026-001] [조회] │ │ + │ ├────────────────────────────────────────────────────────┤ │ + │ │ 주문 목록 테이블 │ │ + │ │ ┌──────┬──────────────┬──────────┬──────────┐ │ │ + │ │ │ 번호 │ 주문번호 │ 고객명 │ 금액 │ │ │ + │ │ │ 1 │ ORD-2026-001 │ (주)가나 │ 1,500만 │ ← 선택 │ │ + │ │ │ 2 │ ORD-2026-002 │ (주)다라 │ 800만 │ │ │ + │ │ └──────┴──────────────┴──────────┴──────────┘ │ │ + │ ├────────────────────────────────────────────────────────┤ │ + │ │ 견적서 미리보기 [↻ 새로고침] [↗ 전체보기] │ │ + │ │ ┌──────────────────────────────────────────────────┐ │ │ + │ │ │ ╔══════════════════════════════════════════╗ │ │ │ + │ │ │ ║ 견 적 서 ║ │ │ │ + │ │ │ ║──────────────────────────────────────────║ │ │ │ + │ │ │ ║ 수신: (주)가나 ║ │ │ │ + │ │ │ ║ 주문번호: ORD-2026-001 ║ │ │ │ + │ │ │ ║ ┌────┬──────────┬────┬──────────┐ ║ │ │ │ + │ │ │ ║ │ No │ 품목명 │ 수량│ 단가 │ ║ │ │ │ + │ │ │ ║ │ 1 │ 부품A │ 100│ 50,000 │ ║ │ │ │ + │ │ │ ║ │ 2 │ 부품B │ 50 │ 100,000 │ ║ │ │ │ + │ │ │ ║ └────┴──────────┴────┴──────────┘ ║ │ │ │ + │ │ │ ║ 합계: 15,000,000원 ║ │ │ │ + │ │ │ ╚══════════════════════════════════════════╝ │ │ │ + │ │ └──────────────────────────────────────────────────┘ │ │ + │ │ * 컴포넌트 크기에 맞게 축소(scale) 렌더링 │ │ + │ │ * 클릭하면 전체 보기 모달 열림 │ │ + │ └────────────────────────────────────────────────────────┘ │ + └──────────────────────────────────────────────────────────────┘ + +[5] "전체보기" 버튼 또는 인라인 리포트 클릭 → 모달로 전체 크기 표시 + ┌──────────────────────────────────────────────────────────────┐ + │ 견적서 — ORD-2026-001 [PDF] [×] │ + │ ┌──────────────────────────────────────────────────────┐ │ + │ │ │ │ + │ │ (A4 크기 리포트 전체 표시) │ │ + │ │ ReportListPreviewModal 사용 │ │ + │ │ (기존 모달 그대로) │ │ + │ │ │ │ + │ └──────────────────────────────────────────────────────┘ │ + │ 페이지: [< 1 / 1 >] │ + └──────────────────────────────────────────────────────────────┘ +``` + +--- + +### 플로우 C: 모달 모드로 설정한 경우 (실행 시점 — 모달 모드) + +``` +[1] 관리자가 설정 패널에서 displayMode = "모달" 선택 + reportId = 12 (견적서) 설정 + +[2] 사용자가 화면 접속 시 → 리포트 이름 + "보기" 버튼만 표시 + ┌────────────────────────────────────────────────────────┐ + │ 견적서 미리보기 │ + │ ┌──────────────────────────────────────────────────┐ │ + │ │ 📄 견적서 [보기] │ │ + │ └──────────────────────────────────────────────────┘ │ + └────────────────────────────────────────────────────────┘ + +[3] "보기" 버튼 클릭 → ReportListPreviewModal 모달 열림 (기존과 동일) + → formData의 order_no 값이 $1 파라미터로 자동 바인딩 + → 모달 안에서 리포트 렌더링 + PDF 다운로드 가능 +``` + +--- + +### 플로우 D: reportId 미지정 시 (하위 호환 — 기존 menuObjid 기반) + +``` +[1] 관리자가 설정 패널에서 reportId를 선택하지 않음 (또는 선택 해제) + +[2] 사용자가 /screens/45?menuObjid=789 로 접속 + → menuObjid=789에 연결된 리포트 목록 자동 조회 + → 기존과 동일하게 리포트 버튼 목록 표시 + + ┌────────────────────────────────────────────────────────┐ + │ 리포트 │ + │ ┌──────────────────────────────────────────────────┐ │ + │ │ 📄 견적서 │ │ + │ │ 📄 발주서 │ │ + │ │ 📄 거래명세서 │ │ + │ └──────────────────────────────────────────────────┘ │ + └────────────────────────────────────────────────────────┘ + +[3] 버튼 클릭 → 해당 리포트의 ReportListPreviewModal 모달 열림 + (현재 동작과 100% 동일) +``` + +--- + +### 데이터 흐름 요약 + +``` +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ +│ 화면 디자이너 │ │ 화면 뷰어 │ │ 백엔드 API │ +│ (설정 시점) │ │ (실행 시점) │ │ │ +├─────────────────┤ ├──────────────────┤ ├─────────────────────┤ +│ │ │ │ │ │ +│ ConfigPanel │ │ ReportViewer │ │ GET /admin/reports │ +│ ↓ reportId │ 저장 │ Component │ │ → 리포트 목록 │ +│ ↓ displayMode │ ──→ │ ↓ │ │ │ +│ ↓ paramMappings │ ↓ config 로드 │ │ GET /admin/reports │ +│ │ │ ↓ │ │ /:reportId │ +│ ReportSelect │ │ reportId 있음? │ │ → 리포트 상세 │ +│ Modal │ │ ├─ Yes │ │ │ +│ ↓ │ │ │ displayMode?│ │ POST /admin/reports │ +│ reportApi │ │ │ ├─ inline │ │ /:reportId/queries│ +│ .getReports() │ │ │ │ → Inline │ │ /:queryId/execute │ +│ │ │ │ │ Renderer │ ──→ │ → 쿼리 실행 │ +│ │ │ │ └─ modal │ │ params: { $1: ... }│ +│ │ │ │ → 버튼 │ │ │ +│ │ │ │ → 클릭시 │ │ │ +│ │ │ │ 모달 │ │ │ +│ │ │ │ │ │ │ +│ │ │ └─ No (fallback) │ GET /admin/reports │ +│ │ │ → menuObjid │ │ /by-menu/:objid │ +│ │ │ 기반 목록 │ │ → 메뉴별 리포트 │ +│ │ │ │ │ │ +│ │ │ formData 변경 │ │ │ +│ │ │ → 즉시 재실행 │ │ │ +└─────────────────┘ └──────────────────┘ └─────────────────────┘ +``` + +--- + +### 관련 URL 정리 + +| 화면 | URL | 역할 | +|------|-----|------| +| 화면 목록 (관리자) | `/admin/screenMng/screenMngList` | 화면 목록 + 더블클릭 시 디자이너 진입 | +| 화면 디자이너 (관리자) | (URL 변화 없음, 같은 페이지 내 ScreenDesigner 렌더링) | 컴포넌트 배치 + 설정 | +| 리포트 관리 (관리자) | `/admin/screenMng/reportList` | 리포트 CRUD + 디자이너 진입 | +| 리포트 디자이너 (관리자) | `/admin/screenMng/reportList/designer/{reportId}` | SQL + 레이아웃 + 바인딩 설정 | +| 화면 뷰어 (사용자) | `/screens/{screenId}?menuObjid={menuObjid}` | 실제 화면 사용 | +| POP 화면 뷰어 (사용자) | `/pop/screens/{screenId}` | POP 화면 사용 | + +--- + +## 확정된 결정 사항 + +| # | 결정 항목 | 확정 내용 | +|---|----------|----------| +| 1 | **리포트 선택 방식** | **reportId 직접 지정** — 설정 패널에서 "리포트 선택" 버튼 → 리포트 목록 모달 → 선택하면 reportId 저장. menuObjid 기반은 fallback으로 유지 | +| 2 | **표시 모드** | **모달 + 인라인 선택 가능** — 설정에서 displayMode 선택 (modal / inline) | +| 3 | **파라미터 바인딩** | **단순 키만** — `formData['order_no']` 같은 1단계 키만 지원 (현재 방식 유지) | +| 4 | **자동 갱신** | **즉시 자동 갱신** — formData 변경 시 즉시 리포트 재실행 | +| 5 | **버그 수정 범위** | **screens + pop/screens 모두 수정** — menuObjid 미전달 버그 | + +--- + +## 1. 현재 코드 현황 + +### v2-report-viewer 현재 파일 구조 + +``` +frontend/lib/registry/components/v2-report-viewer/ +├── index.ts # V2ReportViewerDefinition (28줄) +├── types.ts # ReportViewerConfig, ReportParamMapping (18줄) +├── ReportViewerComponent.tsx # 메인 컴포넌트 (133줄) +├── ReportViewerConfigPanel.tsx # 설정 패널 (115줄) +└── ReportViewerRenderer.tsx # ComponentRegistry 등록 (12줄) +``` + +### 현재 문제점 + +| 문제 | 원인 | +|------|------| +| reportId를 직접 지정할 수 없음 | 설정 패널에 리포트 선택 UI 없음 | +| menuObjid 없으면 아무것도 안 보임 | reportId fallback 없음 | +| 인라인 렌더링 불가 | 모달만 지원 | +| formData 변경 시 자동 갱신 없음 | 감지 로직 없음 | +| `/screens/` 페이지에서 menuObjid 전달 안 됨 | ScreenContextProvider에 props 미전달 | + +--- + +## 2. 구현 단계 (7 Steps) + +### Step 1: menuObjid 미전달 버그 수정 [난이도: 낮음] + +**수정 파일 (2개):** + +| 파일 | 현재 | 변경 | +|------|------|------| +| `frontend/app/(main)/screens/[screenId]/page.tsx` | `` (props 없음, 1377행) | Wrapper에서 `useSearchParams`로 `menuObjid` 파싱 후 전달 | +| `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` | `` (props 없음, 348행) | 동일 | + +**검증:** +- [ ] `/screens/{screenId}?menuObjid=123` 접속 → `screenContext.menuObjid === 123` 확인 + +--- + +### Step 2: ReportViewerConfig 타입 확장 [난이도: 낮음] + +**수정 파일:** `frontend/lib/registry/components/v2-report-viewer/types.ts` + +```typescript +export interface ReportViewerConfig extends ComponentConfig { + title?: string; + paramMappings?: ReportParamMapping[]; + + reportId?: number; // 리포트 목록 모달에서 선택한 리포트 ID + reportName?: string; // 선택한 리포트명 (설정 패널 표시용) + displayMode?: "modal" | "inline"; // 기본: "modal" +} +``` + +--- + +### Step 3: ConfigPanel에 리포트 선택 UI 추가 [난이도: 중간] + +**수정 파일:** `frontend/lib/registry/components/v2-report-viewer/ReportViewerConfigPanel.tsx` + +**추가되는 UI 섹션:** +1. 리포트 선택 영역 (선택 버튼 → 리포트 목록 모달 → 선택 결과 표시 + 해제 버튼) +2. 표시 모드 Select (모달 / 인라인) + +**리포트 선택 모달:** `reportApi.getReports({ limit: 100, useYn: 'Y' })` → 검색 + 테이블 → 행 클릭 시 선택 완료. + +--- + +### Step 4: ReportInlineRenderer + useReportExecution 추출 [난이도: 높음] + +**신규 파일 (2개):** + +| 파일 | 역할 | 위치 근거 | +|------|------|----------| +| `frontend/hooks/useReportExecution.ts` | 리포트 로드 + 쿼리 실행 공용 훅 (~120줄) | CLAUDE.md 네이밍 규칙: 훅은 `frontend/hooks/`에 배치. `ReportListPreviewModal`과 `ReportInlineRenderer` 양쪽에서 공유하므로 특정 컴포넌트 폴더가 아닌 공용 위치가 적합 | +| `frontend/components/report/ReportInlineRenderer.tsx` | 모달 없이 인라인 렌더링 (~200줄) | `ReportListPreviewModal`과 동일 레벨에 배치. v2-report-viewer 전용이 아니라 향후 다른 컨텍스트(예: 대시보드 위젯)에서도 재사용 가능한 범용 렌더러이므로 `components/report/`가 적합 | + +**useReportExecution:** `ReportListPreviewModal`에서 리포트 로드 + 쿼리 실행 로직을 추출한 공용 훅. + +**ReportInlineRenderer:** `useReportExecution` 훅 사용 + ResizeObserver로 scale 축소 렌더링 + 첫 페이지만 표시. + +--- + +### Step 5: ReportViewerComponent에 reportId 직접 지정 + displayMode 분기 [난이도: 중간] + +**수정 파일:** `frontend/lib/registry/components/v2-report-viewer/ReportViewerComponent.tsx` + +**렌더링 분기:** + +``` +config.reportId 있음: + ├─ displayMode === "inline" → ReportInlineRenderer + 헤더(제목, 새로고침, 전체보기) + └─ displayMode === "modal" → 리포트명 + "보기" 버튼 → 클릭 시 모달 + +config.reportId 없음 (fallback): + → menuObjid 기반 리포트 목록 (기존 동작 100% 유지) +``` + +--- + +### Step 6: formData 변경 시 즉시 자동 갱신 [난이도: 중간] + +**수정 파일:** `ReportViewerComponent.tsx` + +`ScreenContext.formData` 변경 감지 → `contextParams` 재계산 → `refreshKey` 증가 → `ReportInlineRenderer`에 전달하여 즉시 재실행. + +--- + +### Step 7: 통합 테스트 + 가이드 문서 업데이트 [난이도: 낮음] + +**검증 시나리오:** 플로우 A~D 전체 검증 + `npx tsc --noEmit` 오류 없음. + +**가이드 문서 업데이트:** +- [ ] `docs/V2_컴포넌트_분석_가이드.md` — V2 컴포넌트 목록에 `v2-report-viewer` 추가 (18개 → 19개) +- [ ] `docs/V2_컴포넌트_연동_가이드.md` — 6.1 연동 능력 매트릭스에 `v2-report-viewer` 행 추가 +- [ ] `docs/screen-implementation-guide/화면개발_표준_가이드.md` — V2 컴포넌트 목록에 `v2-report-viewer` 추가 (23개 → 24개) + +--- + +## 3. 파일 변경 요약 + +### 수정 파일 (5개) + +| 파일 | Step | 핵심 변경 | +|------|------|-----------| +| `frontend/app/(main)/screens/[screenId]/page.tsx` | 1 | ScreenContextProvider에 menuObjid 전달 | +| `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` | 1 | 동일 | +| `frontend/lib/registry/components/v2-report-viewer/types.ts` | 2 | `reportId`, `reportName`, `displayMode` 필드 추가 | +| `frontend/lib/registry/components/v2-report-viewer/ReportViewerConfigPanel.tsx` | 3 | 리포트 선택 모달 + displayMode Select | +| `frontend/lib/registry/components/v2-report-viewer/ReportViewerComponent.tsx` | 5,6 | reportId 분기 + displayMode 분기 + 자동 갱신 | + +### 신규 파일 (2개) + +| 파일 | Step | 역할 | +|------|------|------| +| `frontend/hooks/useReportExecution.ts` | 4 | 리포트 로드 + 쿼리 실행 공용 훅 | +| `frontend/components/report/ReportInlineRenderer.tsx` | 4 | 모달 없이 인라인 리포트 렌더링 | + +### 수정 대상 (리팩토링, 선택적) + +| 파일 | Step | 변경 | +|------|------|------| +| `frontend/components/report/ReportListPreviewModal.tsx` | 4 | 내부 로드/실행 로직을 `useReportExecution`으로 교체 (기능 변경 없음) | + +### 가이드 문서 업데이트 (3개) + +| 파일 | Step | 변경 | +|------|------|------| +| `docs/V2_컴포넌트_분석_가이드.md` | 7 | V2 컴포넌트 목록에 `v2-report-viewer` 추가 | +| `docs/V2_컴포넌트_연동_가이드.md` | 7 | 연동 능력 매트릭스에 `v2-report-viewer` 행 추가 | +| `docs/screen-implementation-guide/화면개발_표준_가이드.md` | 7 | V2 컴포넌트 목록에 `v2-report-viewer` 추가 | + +--- + +## 4. 구현 순서 및 의존성 + +``` +Step 1 menuObjid 버그 수정 ─────────────────── (독립) + ↓ +Step 2 types.ts 확장 ───────────────────────── (Step 3, 5의 기반) + ↓ +Step 3 ConfigPanel 리포트 선택 UI ──────────── (Step 2 의존) + ↓ +Step 4 useReportExecution + ReportInlineRenderer (가장 큰 작업) + ↓ +Step 5 ReportViewerComponent 분기 렌더링 ───── (Step 2, 4 의존) + ↓ +Step 6 즉시 자동 갱신 ──────────────────────── (Step 5 의존) + ↓ +Step 7 통합 테스트 + 가이드 문서 업데이트 +``` + +**병렬 가능:** +- Step 1 + Step 2: 동시 진행 가능 +- Step 3 + Step 4: Step 2 완료 후 동시 진행 가능 + +--- + +## 5. V2 이벤트 시스템과의 관계 + +`V2_컴포넌트_연동_가이드.md`에서 정의한 V2 표준 이벤트 시스템(`V2_EVENTS`, `dispatchV2Event`, `subscribeV2Event`)과의 관계를 정리합니다. + +### 현재 v2-report-viewer가 사용하지 않는 이유 + +| V2 이벤트 | 사용 여부 | 이유 | +|-----------|----------|------| +| `tableListDataChange` | 구독 안 함 | 리포트 뷰어는 테이블 데이터 변경이 아닌 `ScreenContext.formData`를 통해 파라미터를 받음 | +| `beforeFormSave` / `afterFormSave` | 구독 안 함 | 리포트 뷰어는 데이터를 저장하지 않음 (읽기 전용 표시 컴포넌트) | +| `refreshTable` | 구독 안 함 | 리포트 갱신은 `refreshKey` prop으로 처리. 테이블 갱신 이벤트와는 무관 | +| `componentDataTransfer` | 구독 안 함 | 리포트 뷰어는 DataReceivable이 아님 (데이터를 수신하여 편집하는 컴포넌트가 아님) | + +### formData 공유 방식 + +`v2-report-viewer`는 `ScreenContext`의 `formData`를 통해 다른 컴포넌트와 통신합니다: + +``` +v2-input (order_no 입력) + → ScreenContext.formData 업데이트 + → v2-report-viewer가 formData 변경 감지 + → buildContextParams로 쿼리 파라미터 생성 + → useReportExecution으로 쿼리 실행 +``` + +이 방식은 `V2_컴포넌트_연동_가이드.md` 4.3절 ScreenContext의 `formData` 공유 패턴과 일치합니다. + +### 향후 확장 시 이벤트 도입 가능성 + +리포트 실행 완료 후 다른 컴포넌트에 알림이 필요한 경우(예: 리포트 로드 완료 시 집계 위젯 갱신), V2 이벤트를 추가할 수 있습니다. 현재 Phase에서는 불필요합니다. + +--- + +## 6. 충돌 사전 검사 대상 + +구현 시작 전 아래 이름들이 현재 코드베이스에 **0건**인지 Grep 확인 필수: + +``` +ReportInlineRenderer, useReportExecution, +ReportSelectModal, displayMode (v2-report-viewer 내), +reportName (v2-report-viewer/types.ts 내) +``` + +--- + +## 7. 주의사항 + +1. **하위 호환 필수**: 모든 신규 필드는 optional. `reportId` 없으면 기존 menuObjid 기반 동작 그대로 유지. +2. **reportId 타입**: `ReportMaster.report_id`는 `string`이지만 실제 값은 숫자 문자열. API 호출 시 `String(reportId)`로 변환. +3. **멀티테넌시**: `reportApi.getReports()` 호출 시 백엔드에서 자동으로 company_code 필터링됨. +4. **디자인 모드 보호**: `isDesignMode`일 때 API 호출, 자동 갱신 모두 스킵. +5. **ReportListPreviewModal 수정 최소화**: 기존 모달은 그대로 유지. 공통 로직만 훅으로 추출. +6. **인라인 렌더링 스케일**: `ResizeObserver`로 컨테이너 크기 감지 → `transform: scale(containerWidth / canvasWidth)`. +7. **V2 컴포넌트 규칙 준수**: `v2-` 접두사, `V2ReportViewerDefinition` 네이밍, `screen_layouts_v2` JSONB 저장. +8. **각 Step 완료 시 필수**: `cd frontend && npx tsc --noEmit` + +--- + +## 8. 핵심 원칙 + +| 역할 | 담당 | +|------|------| +| SQL 작성, 컴포넌트 레이아웃, queryId+field 연결, 숫자 포맷/합계 | **리포트 디자이너** (기존, 수정 없음) | +| 어떤 리포트를 보여줄지 (reportId), 언제 실행할지 (자동 갱신), 어디에 표시할지 (displayMode) | **화면관리 v2-report-viewer** (이번 구현) | + +리포트 디자이너의 코드는 이번 작업에서 **수정하지 않는다**. + +--- + +## 9. 연동 능력 매트릭스 (Step 7에서 가이드 문서에 추가할 내용) + +| 컴포넌트 | 이벤트 발행 | 이벤트 구독 | DataProvider | DataReceiver | Context 사용 | +|----------|:-----------:|:-----------:|:------------:|:------------:|:------------:| +| `v2-report-viewer` | - | - | - | - | Screen (formData, menuObjid) | + +| 소스 컴포넌트 | 타겟 컴포넌트 | 연동 방식 | 용도 | +|--------------|--------------|----------|------| +| `v2-input` / `v2-table-list` | `v2-report-viewer` | ScreenContext.formData | 파라미터 바인딩 | +| `v2-report-viewer` | `ReportListPreviewModal` | props (report, contextParams) | 전체 보기 모달 | diff --git a/.cursor/rules/modal-design.mdc b/.cursor/rules/modal-design.mdc new file mode 100644 index 00000000..e3040f35 --- /dev/null +++ b/.cursor/rules/modal-design.mdc @@ -0,0 +1,18 @@ +--- +description: 모달(Dialog) 컴포넌트 구현 시 WACE 디자인 시스템 적용 +globs: **/*.tsx +alwaysApply: false +--- + +모달 컴포넌트 구현 시 반드시 @design-system.md 의 패턴을 따를 것. + +## 핵심 규칙 요약 + +1. **Shell**: `DialogContent`에 `p-0 [&>button]:hidden flex flex-col h-[80vh] overflow-hidden` 필수 +2. **접근성**: ``, `` 반드시 포함 +3. **헤더**: `px-6 py-4 border-b` + 아이콘(`w-4 h-4 text-blue-600`) + 제목(`text-base font-semibold`) + X 닫기 버튼 +4. **탭**: shadcn `` 사용 금지 → `@design-system.md` Section 2의 커스텀 버튼 패턴 사용 +5. **콘텐츠**: `flex-1 overflow-y-auto px-6 py-4` +6. **Footer**: `px-6 py-4 border-t flex justify-end gap-2` + 취소(`outline`) + 저장(`bg-blue-600`) +7. **폼 필드**: Label `text-xs font-medium` + Input/Select `h-9 text-sm`, 그룹 간격 `space-y-3` +8. **섹션**: 강조 섹션 `bg-teal-50 border-teal-200 rounded-xl`, 일반 섹션 `bg-white border-border rounded-xl` diff --git a/.cursor/rules/web-verify-login.mdc b/.cursor/rules/web-verify-login.mdc new file mode 100644 index 00000000..7dcd1e83 --- /dev/null +++ b/.cursor/rules/web-verify-login.mdc @@ -0,0 +1,24 @@ +--- +description: +globs: +alwaysApply: true +--- + +# 웹 검증 로그인 정보 + +웹 검증(web-verify), 브라우저 테스트, UI 확인 등 로컬 서버에 접속이 필요한 모든 작업에서 아래 정보를 사용해야 합니다. + +## 로컬 서버 정보 + +- 프론트엔드: http://localhost:9771 +- 백엔드: http://localhost:8080 (API 베이스: http://localhost:8080/api) + +## 로그인 계정 (필수) + +- 아이디: wace +- 비밀번호: qlalfqjsgh11 + +## 주의사항 + +- `admin / admin123` 등 임의의 계정을 사용하지 마세요. +- 서브에이전트(web-verifier 등)에 작업을 위임할 때도 반드시 위 계정 정보를 prompt에 포함시켜야 합니다. diff --git a/.cursor/skills/code-fix/SKILL.md b/.cursor/skills/code-fix/SKILL.md new file mode 100644 index 00000000..4299142b --- /dev/null +++ b/.cursor/skills/code-fix/SKILL.md @@ -0,0 +1,34 @@ +--- +name: code-fix +description: 리포트 코드 문제 수정 워크플로우. 리포트 관련 버그, 에러를 진단하고 수정. 에러 수정 요청 시 적용. +disable-model-invocation: true +--- + +# 리포트 코드 문제 수정 워크플로우 + +## 수정 범위 제약 + +리포트 관련 파일만 수정. 원인이 리포트 밖에 있으면 보고만. + +## 진단 절차 + +1. 에러 메시지 분석 +2. 에러 파일이 리포트 범위 내인지 확인 +3. 근본 원인 파악 +4. 리포트 범위 내에서 최소한의 수정 +5. 린트/타입 검사로 검증 + +## 리포트 특화 에러 패턴 + +| 에러 | 원인 | 해결 | +|------|------|------| +| 디자이너 렌더링 실패 | Context 상태 불일치 | ReportDesignerContext 확인 | +| 프리뷰 빈 화면 | 데이터 직렬화 오류 | report.ts 타입 확인 | +| API 404 | 라우트 미등록 | reportRoutes.ts 확인 | +| company_code 누락 | 서비스 필터링 빠짐 | reportService.ts 확인 | + +## 수정 후 검증 + +```bash +cd frontend && npx tsc --noEmit +``` diff --git a/.cursor/skills/code-review/SKILL.md b/.cursor/skills/code-review/SKILL.md new file mode 100644 index 00000000..f486a355 --- /dev/null +++ b/.cursor/skills/code-review/SKILL.md @@ -0,0 +1,43 @@ +--- +name: code-review +description: 리포트 코드 검수 워크플로우. 리포트 관련 코드 변경 검토 시 사용. +disable-model-invocation: true +--- + +# 리포트 코드 검수 워크플로우 + +## 수정 범위 제약 + +리포트 관련 파일 변경만 검수. 범위 밖 파일 문제는 보고만. + +## 절차 + +1. `git diff`로 변경 확인 +2. 변경 파일이 리포트 범위 내인지 확인 +3. 체크리스트 기반 검수 +4. 피드백 작성 + +## 검수 체크리스트 + +### 필수 +- [ ] 멀티테넌시: company_code 필터링 +- [ ] API: `reportApi.ts` 클라이언트 사용 +- [ ] 타입: `npx tsc --noEmit` 통과 +- [ ] 리포트 밖 파일 수정 없음 + +### 권장 +- [ ] 컴포넌트 500줄 이하 +- [ ] any 타입 미사용 +- [ ] console.log 잔류 없음 + +## 피드백 형식 + +```markdown +## 코드 리뷰 결과 + +### 치명적 (반드시 수정) +- [파일:라인] 설명 + +### 범위 밖 발견 (수정 금지, 보고만) +- [파일] 설명 +``` diff --git a/.cursor/skills/component-dev/SKILL.md b/.cursor/skills/component-dev/SKILL.md new file mode 100644 index 00000000..58910603 --- /dev/null +++ b/.cursor/skills/component-dev/SKILL.md @@ -0,0 +1,47 @@ +--- +name: component-dev +description: 리포트 뷰어 V2 컴포넌트 개발 가이드. v2-report-viewer 등 리포트 관련 V2 컴포넌트 개발 시 사용. +--- + +# 리포트 V2 컴포넌트 개발 가이드 + +## 수정 범위 제약 + +리포트 관련 V2 컴포넌트만 수정: +- `v2-report-viewer/` +- 리포트 연동 컴포넌트 + +다른 V2 컴포넌트(`v2-table-list`, `v2-input` 등)는 수정하지 않는다. + +## V2 컴포넌트 핵심 규칙 + +- `v2-` 접두사 필수 (원본 폴더 수정 금지) +- 저장: `component_url + overrides` (차이값만) +- Zod 스키마에 `.passthrough()` 필수 +- `isDesignMode` 체크하여 API 호출 스킵 +- `beforeFormSave` 이벤트로 느슨한 결합 +- `autoFilter`로 멀티테넌시 필터링 + +## 표준 파일 구조 + +``` +frontend/lib/registry/components/v2-report-viewer/ +├── index.ts +├── ReportViewerRenderer.tsx +├── ReportViewerComponent.tsx +├── ReportViewerConfigPanel.tsx +└── types.ts +``` + +## 표준 Props + +```typescript +interface StandardComponentProps { + component: ComponentData; + isDesignMode?: boolean; + formData?: Record; + onFormDataChange?: (fieldName: string, value: any) => void; + companyCode?: string; + refreshKey?: number; +} +``` diff --git a/.cursor/skills/component-registry/SKILL.md b/.cursor/skills/component-registry/SKILL.md new file mode 100644 index 00000000..2a50e159 --- /dev/null +++ b/.cursor/skills/component-registry/SKILL.md @@ -0,0 +1,48 @@ +--- +name: component-registry +description: 리포트 디자이너 컴포넌트 가이드. 리포트 디자이너 내 컴포넌트 구조, 추가/수정 방법. 리포트 디자이너 컴포넌트 작업 시 사용. +--- + +# 리포트 디자이너 컴포넌트 가이드 + +## 수정 범위 제약 + +`frontend/components/report/designer/` 내 파일만 수정. +화면 빌더의 일반 컴포넌트 레지스트리(`lib/registry/components/`)는 수정하지 않는다. + +## 리포트 디자이너 컴포넌트 구조 + +``` +frontend/components/report/designer/ +├── ReportDesignerCanvas.tsx # 캔버스 +├── ReportDesignerToolbar.tsx # 툴바 +├── ReportComponentPalette.tsx # 컴포넌트 팔레트 +├── properties/ # 속성 패널 +│ ├── TextProperties.tsx +│ ├── ImageProperties.tsx +│ ├── TableProperties.tsx +│ ├── CardProperties.tsx +│ └── PageNumberProperties.tsx +└── modals/ # 설정 모달 + ├── ComponentSettingsModal.tsx + ├── SettingsModalShell.tsx + ├── TextLayoutTabs.tsx + ├── ImageLayoutTabs.tsx + ├── TableLayoutTabs.tsx + └── GridCellDropZone.tsx +``` + +## 컴포넌트 추가 시 절차 + +1. `types/report.ts`에 새 컴포넌트 타입 추가 +2. `designer/properties/`에 속성 패널 생성 +3. `designer/modals/`에 설정 모달 생성 (필요 시) +4. `ReportDesignerCanvas.tsx`에 렌더링 로직 추가 +5. `ReportComponentPalette.tsx`에 팔레트 항목 추가 + +## 전역 상태 + +`frontend/contexts/ReportDesignerContext.tsx`로 관리: +- 선택된 컴포넌트 +- 캔버스 상태 +- 저장/불러오기 diff --git a/.cursor/skills/component-registry/reference.md b/.cursor/skills/component-registry/reference.md new file mode 100644 index 00000000..91afd9da --- /dev/null +++ b/.cursor/skills/component-registry/reference.md @@ -0,0 +1,96 @@ +# TableListComponent 상세 참조 + +## 주요 상태 (State) + +```typescript +// 데이터 +const [tableData, setTableData] = useState([]); +const [filteredData, setFilteredData] = useState([]); +const [loading, setLoading] = useState(false); + +// 편집 +const [editingCell, setEditingCell] = useState<{ + rowIndex: number; colIndex: number; columnName: string; originalValue: any; +} | null>(null); +const [pendingChanges, setPendingChanges] = useState>>(new Map()); +const [validationErrors, setValidationErrors] = useState>>(new Map()); + +// 필터 +const [headerFilters, setHeaderFilters] = useState>>(new Map()); +const [filterGroups, setFilterGroups] = useState([]); +const [globalSearchText, setGlobalSearchText] = useState(""); + +// 컬럼 +const [columnWidths, setColumnWidths] = useState>({}); +const [columnOrder, setColumnOrder] = useState([]); +const [columnVisibility, setColumnVisibility] = useState>({}); + +// 선택/정렬/페이지네이션 +const [selectedRows, setSelectedRows] = useState>(new Set()); +const [sortBy, setSortBy] = useState(""); +const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); +const [currentPage, setCurrentPage] = useState(1); +const [pageSize, setPageSize] = useState(20); +``` + +## 타입 정의 + +```typescript +type ValidationRule = { + required?: boolean; + min?: number; max?: number; + minLength?: number; maxLength?: number; + pattern?: RegExp; + customMessage?: string; + validate?: (value: any, row: any) => string | null; +}; + +interface FilterCondition { + id: string; column: string; + operator: "equals" | "notEquals" | "contains" | "notContains" | + "startsWith" | "endsWith" | "greaterThan" | "lessThan" | + "greaterOrEqual" | "lessOrEqual" | "isEmpty" | "isNotEmpty"; + value: string; +} + +interface FilterGroup { id: string; logic: "AND" | "OR"; conditions: FilterCondition[]; } + +interface TableState { + columnWidths: Record; + columnOrder: string[]; + sortBy: string; sortOrder: "asc" | "desc"; + frozenColumns: string[]; + columnVisibility: Record; +} +``` + +## 캐싱 전략 + +```typescript +const tableColumnCache = new Map(); +const TABLE_CACHE_TTL = 5 * 60 * 1000; // 5분 +``` + +## 키보드 네비게이션 + +| 키 | 동작 | +|---|---| +| Arrow Keys | 셀 이동 | +| Tab/Shift+Tab | 다음/이전 셀 | +| F2 | 편집 모드 | +| Enter | 저장 후 아래로 | +| Escape | 편집 취소 | +| Ctrl+C | 복사 | +| Delete | 셀 값 삭제 | + +## 필수 Import + +```typescript +import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; +import { TableListConfig, ColumnConfig } from "./types"; +import { tableTypeApi } from "@/lib/api/screen"; +import { entityJoinApi } from "@/lib/api/entityJoin"; +import { codeCache } from "@/lib/caching/codeCache"; +import * as XLSX from "xlsx"; +import { toast } from "sonner"; +``` diff --git a/.cursor/skills/github/SKILL.md b/.cursor/skills/github/SKILL.md new file mode 100644 index 00000000..d720301b --- /dev/null +++ b/.cursor/skills/github/SKILL.md @@ -0,0 +1,38 @@ +--- +name: github +description: Git 작업 워크플로우. 리포트 관련 변경사항 커밋 시 사용. +disable-model-invocation: true +--- + +# Git 작업 워크플로우 + +## 수정 범위 확인 + +커밋 전 `git diff`로 리포트 관련 파일만 변경되었는지 확인. +리포트 밖 파일이 변경되어 있으면 **사용자에게 확인** 후 진행. + +## 커밋 메시지 형식 + +``` +type(report): 설명 +``` + +타입: +- `feat(report)`: 리포트 새 기능 +- `fix(report)`: 리포트 버그 수정 +- `refactor(report)`: 리포트 리팩토링 +- `style(report)`: 리포트 스타일 변경 +- `docs(report)`: 리포트 문서 + +## 커밋 절차 + +1. `git status`로 변경 확인 +2. 리포트 밖 파일 변경 여부 체크 +3. `git add`로 리포트 관련 파일만 스테이징 +4. Conventional Commit 형식으로 커밋 + +## 주의사항 + +- `git push --force` 금지 +- `git commit --amend` 주의 (push 전에만) +- git config 수정 금지 diff --git a/.cursor/skills/implement/SKILL.md b/.cursor/skills/implement/SKILL.md new file mode 100644 index 00000000..3a1a5a74 --- /dev/null +++ b/.cursor/skills/implement/SKILL.md @@ -0,0 +1,37 @@ +--- +name: implement +description: 리포트 기능 4단계 구현 워크플로우. 조사→정리→구현→통합 단계로 리포트 관련 기능을 체계적으로 구현. 리포트 기능 구현 요청 시 사용. +--- + +# 리포트 기능 4단계 구현 워크플로우 + +## 수정 범위 제약 + +리포트 관련 파일만 수정. 그 외 파일은 절대 수정하지 않는다. +허용: `components/report/**`, `reportRoutes`, `reportController`, `reportService`, `reportApi.ts`, `report.ts` 등 + +## 단계 1: 조사 (Explore) + +Task tool의 `explore` subagent를 사용: +- 리포트 관련 파일 구조 파악 (`reportdocs/INDEX.md` 참조) +- 기존 디자이너 컴포넌트 패턴 분석 +- 영향 범위가 리포트 밖으로 나가지 않는지 확인 + +## 단계 2: 정리 (Plan) + +- 구현 계획 수립 (리포트 파일만 변경 목록) +- 인터페이스/타입 설계 (`types/report.ts`) +- API 엔드포인트 설계 (`reportRoutes.ts`) +- `reportdocs/STATUS.md` 갱신 + +## 단계 3: 구현 (Implement) + +- 타입 → 백엔드 → 프론트엔드 순서 +- 각 파일 완료 시 린트 확인 +- 리포트 밖 파일 수정 필요 시 **중단하고 사용자에게 확인** + +## 단계 4: 통합 (Integrate) + +- `npx tsc --noEmit`으로 타입 검사 +- import 정합성 확인 +- 멀티테넌시 체크리스트 검증 diff --git a/.cursor/skills/next-feature/SKILL.md b/.cursor/skills/next-feature/SKILL.md new file mode 100644 index 00000000..e44991cd --- /dev/null +++ b/.cursor/skills/next-feature/SKILL.md @@ -0,0 +1,37 @@ +--- +name: next-feature +description: 리포트 관련 Next.js 15 App Router 기능 구현 워크플로우. 리포트 페이지, API 라우트 구현 시 사용. +--- + +# 리포트 Next.js 기능 구현 워크플로우 + +## 수정 범위 제약 + +리포트 관련 라우트만 수정. 다른 페이지는 수정하지 않는다. + +허용 라우트: +- `app/(main)/admin/screenMng/reportList/page.tsx` +- `app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx` + +## 프로젝트 구조 (리포트 관련) + +``` +frontend/ +├── app/(main)/admin/screenMng/reportList/ +│ ├── page.tsx # 리포트 목록 +│ └── designer/[reportId]/page.tsx # 리포트 디자이너 +├── components/report/ # 리포트 컴포넌트 +├── lib/api/reportApi.ts # 리포트 API 클라이언트 +├── hooks/useReportList.ts # 리포트 훅 +└── types/report.ts # 리포트 타입 +``` + +## API 호출 규칙 + +```typescript +import { getReportList, createReport } from "@/lib/api/reportApi"; +``` + +환경별 URL 자동 처리: +- `v1.vexplor.com` → `api.vexplor.com` +- `localhost:9771` → `localhost:8080` diff --git a/.cursor/skills/notion-writing/SKILL.md b/.cursor/skills/notion-writing/SKILL.md new file mode 100644 index 00000000..d191c5fc --- /dev/null +++ b/.cursor/skills/notion-writing/SKILL.md @@ -0,0 +1,572 @@ +--- +name: notion-writing +description: Notion MCP로 페이지를 작성할 때 반드시 따라야 하는 규칙. Notion 페이지 생성, 콘텐츠 작성, 문서화 요청 시 자동 적용. +--- + +# Notion 작성 규칙 + +## 저장 위치 + +모든 페이지는 WACE 페이지 하위에 생성한다. + +``` +WACE 페이지 ID: 31e2a200-9533-80ac-9fcf-d4ad3c676929 +``` + +## MCP 서버 정보 + +- 서버명: `project-0-ERP-node-notion` +- 주요 API: `API-post-search`, `API-post-page`, `API-patch-block-children`, `API-retrieve-a-page` + +## MCP 지원 블록 타입 + +`API-patch-block-children`은 다음 6가지 블록 타입을 지원한다. + +| 블록 타입 | 용도 | +|-----------|------| +| `paragraph` | 일반 텍스트, 핵심 요약(📌), 경고(⚠), 빈 줄 | +| `heading_2` | 대섹션 제목 (## H2) | +| `heading_3` | 소제목 (### H3) | +| `divider` | 섹션 구분선 | +| `code` | 코드 블록, **Mermaid 다이어그램** | +| `bulleted_list_item` | 불릿 리스트 | + +## 마크다운 문법 사용 금지 + +Notion API는 마크다운을 자동 변환하지 않는다. 텍스트에 마크다운을 넣으면 그대로 문자열로 표시된다. + +금지: +- `## 제목` → 그대로 "## 제목"으로 표시됨 +- `---` → 그대로 "---"로 표시됨 +- `` ``` `` → 그대로 백틱 문자로 표시됨 +- `> 인용` → 그대로 "> 인용"으로 표시됨 + +--- + +## 계층 페이지 생성 패턴 + +### 작업 순서 (필수) + +1. `API-post-search`로 대상 페이지/데이터베이스 검색 +2. 검색 결과에서 `object` 필드로 `page`인지 `database`인지 구분 +3. `API-post-page`로 상위 페이지 생성 (반환된 ID 기록) +4. 생성된 페이지 ID를 parent로 하위 페이지 생성 +5. `API-patch-block-children`으로 각 페이지에 콘텐츠 추가 + +### 검색 (API-post-search) + +```json +{ + "query": "검색할 제목", + "page_size": 10 +} +``` + +검색 결과에서 `object` 필드 확인: +- `"object": "page"` → page_id parent 사용 +- `"object": "database"` → database_id parent 사용 + +### 데이터베이스(피드보기) 하위에 페이지 생성 + +DB의 title 속성명을 키로 사용한다 (보통 `"이름"`). + +```json +{ + "parent": {"database_id": ""}, + "properties": {"이름": {"title": [{"text": {"content": "페이지 제목"}}]}}, + "icon": "{\"type\": \"emoji\", \"emoji\": \"📘\"}" +} +``` + +### 페이지 하위에 서브 페이지 생성 + +properties 키는 `"title"`을 사용한다 (DB 하위와 다름에 주의). + +```json +{ + "parent": {"page_id": ""}, + "properties": {"title": {"title": [{"text": {"content": "서브 페이지 제목"}}]}}, + "icon": "{\"type\": \"emoji\", \"emoji\": \"📦\"}" +} +``` + +### parent 유형별 차이 요약 + +| 대상 | parent | properties 키 | 예시 | +|------|--------|---------------|------| +| DB 하위 | `{"database_id": "..."}` | DB의 title 속성명 (예: `"이름"`) | `{"이름": {"title": [...]}}` | +| 페이지 하위 | `{"page_id": "..."}` | `"title"` (고정) | `{"title": {"title": [...]}}` | +| WACE 직접 하위 | `{"page_id": "31e2a200-9533-80ac-9fcf-d4ad3c676929"}` | `"title"` | `{"title": {"title": [...]}}` | + +### icon 설정 + +icon은 반드시 **JSON 문자열**로 전달한다 (객체가 아님): + +``` +"{\"type\": \"emoji\", \"emoji\": \"📘\"}" +``` + +--- + +## 코드 블록 language 목록 + +Notion API가 지원하는 주요 language 값. 지원하지 않는 값을 넣으면 **400 에러** 발생. + +| language | 용도 | 비고 | +|----------|------|------| +| `shell` | 터미널 명령어 실행 | | +| `bash` | Shell 스크립트 파일 내용 | | +| `docker` | Dockerfile 내용 | `dockerfile`은 미지원 | +| `yaml` | docker-compose.yml, CI/CD 워크플로우 | | +| `json` | JSON 설정 파일 | | +| `javascript` | JS 코드 | | +| `typescript` | TS 코드 | | +| `python` | Python 코드 | | +| `sql` | SQL 쿼리 | | +| `html` | HTML 마크업 | | +| `css` | CSS 스타일 | | +| `hcl` | Terraform 설정 | | +| `mermaid` | 다이어그램 (Notion이 자동 시각화) | | +| `plain text` | 일반 텍스트 | | + +코드 블록 JSON 예시: + +```json +{"type": "code", "code": {"rich_text": [{"type": "text", "text": {"content": "docker-compose up -d\ndocker-compose ps"}}], "language": "shell"}} +``` + +### 코드 블록 작성 규칙 + +- 코드 블록 앞에 `bulleted_list_item`으로 **작업명** 라벨 배치 +- 코드 블록 뒤에 `paragraph`로 부가 설명 추가 (code annotation으로 명령어 강조) +- 코드 블록 내 `#` 주석으로 각 명령어 설명 가능 +- 명령어 실행: `language: "shell"` / 스크립트 파일: `language: "bash"` 구분 + +--- + +## 제목 계층 구조 (필수) + +원본 문서의 #/##/### 제목 계층을 반드시 Notion 네이티브 블록으로 변환한다. + +### H2 (대섹션 제목) + +`heading_2` 블록을 사용한다. 앞에 `divider`를 넣어 시각적으로 구분한다. + +```json +{"type": "divider", "divider": {}} +{"type": "heading_2", "heading_2": {"rich_text": [{"type": "text", "text": {"content": "🔹 대섹션 제목"}}]}} +``` + +### H3 (소제목) + +`heading_3` 블록을 사용한다. divider 없이 바로 사용. + +```json +{"type": "heading_3", "heading_3": {"rich_text": [{"type": "text", "text": {"content": "1️⃣ 소제목"}}]}} +``` + +### 결론/완료 기준 섹션 + +```json +{"type": "divider", "divider": {}} +{"type": "heading_2", "heading_2": {"rich_text": [{"type": "text", "text": {"content": "✅ 완료 기준"}}]}} +``` + +## Mermaid 다이어그램 (필수 — 적극 활용) + +다이어그램은 반드시 `code` 블록 + `language: "mermaid"`로 작성한다. +Notion은 Mermaid 코드 블록을 자동으로 시각화 렌더링한다. + +### code 블록 사용법 + +```json +{"type": "code", "code": {"rich_text": [{"type": "text", "text": {"content": "graph TD\n A[시작] --> B[처리]\n B --> C[완료]"}}], "language": "mermaid"}} +``` + +### 다이어그램 유형별 Mermaid 코드 + +**시퀀스 다이어그램 (API 호출 흐름):** +```mermaid +sequenceDiagram + participant U as 사용자 + participant V as v2-report-viewer + participant B as Backend API + participant D as PostgreSQL + U->>V: 1. 메뉴 진입 + V->>B: 2. 매핑 리포트 조회 + B->>D: 3. report_menu_mapping 조회 + D-->>B: 4. 리포트 목록 + B-->>V: 5. 리포트 목록 반환 + U->>V: 6. 리포트 선택 + V->>B: 7. 데이터 + 레이아웃 요청 + B->>D: 8. report_master + 쿼리 실행 + D-->>B: 9. 결과 반환 + B-->>V: 10. 렌더링 데이터 + V-->>U: 11. PDF/미리보기 +``` + +**플로우 다이어그램 (업무 프로세스):** +```mermaid +graph LR + A[거래처관리] --> B[견적관리] + B --> C[수주관리] + C --> D[생산계획] + D --> E[작업지시] + E --> F[POP실적] + F --> G[입고] + G --> H[출고] + H --> I[세금계산서] + C --> J[발주관리] + J --> K[입고관리] + K --> L[품질검사] + L --> G +``` + +**아키텍처 다이어그램 (시스템 구조):** +```mermaid +graph TD + subgraph Frontend + A[업무 화면] --> B[v2-report-viewer] + end + subgraph Backend + C[reportController] --> D[reportService] + end + subgraph Database + E[report_master] + F[report_menu_mapping] + end + B -->|API 호출| C + D -->|쿼리| E + D -->|쿼리| F +``` + +**ER 다이어그램 (DB 구조):** +```mermaid +erDiagram + report_master ||--o{ report_menu_mapping : "1:N" + report_master { + int report_id PK + string report_name + text query_text + jsonb layout_json + string company_code + } + report_menu_mapping { + int id PK + int report_id FK + int menu_objid FK + int sort_order + string company_code + } +``` + +**상태 다이어그램:** +```mermaid +stateDiagram-v2 + [*] --> 초안 + 초안 --> 검토중 + 검토중 --> 승인 + 검토중 --> 반려 + 반려 --> 수정 + 수정 --> 검토중 + 승인 --> 활성 + 활성 --> 비활성 + 초안 --> 삭제 +``` + +### 다이어그램 삽입 시점 (적극 활용) + +| 설명 내용 | 다이어그램 유형 | Mermaid 타입 | +|-----------|----------------|-------------| +| 시스템 아키텍처 | 아키텍처 다이어그램 | `graph TD` + `subgraph` | +| API 호출 흐름 | 시퀀스 다이어그램 | `sequenceDiagram` | +| 업무 프로세스 | 플로우 다이어그램 | `graph LR` 또는 `graph TD` | +| DB 구조 | ER 다이어그램 | `erDiagram` | +| 상태 변화 | 상태 다이어그램 | `stateDiagram-v2` | + +--- + +## 올바른 서식 적용 방법 + +### 핵심 요약 (📌) + +대섹션 시작 직후 paragraph로 핵심 개념을 요약한다. + +```json +{"type": "paragraph", "paragraph": {"rich_text": [ + {"type": "text", "text": {"content": "📌 "}, "annotations": {"bold": true}}, + {"type": "text", "text": {"content": "리포트 시스템"}, "annotations": {"bold": true, "italic": true}}, + {"type": "text", "text": {"content": "은 모든 단계에 걸쳐 있는 "}, "annotations": {"bold": true}}, + {"type": "text", "text": {"content": "횡단 출력 레이어"}, "annotations": {"bold": true, "italic": true}}, + {"type": "text", "text": {"content": "이다."}, "annotations": {"bold": true}} +]}} +``` + +### 경고/안내 텍스트 (⚠) + +주의사항이나 미완성 안내에 사용한다. + +```json +{"type": "paragraph", "paragraph": {"rich_text": [ + {"type": "text", "text": {"content": "⚠ 절대 main/develop에 push 금지. 개인 브랜치에서만 작업할 것"}, "annotations": {"bold": true}} +]}} +``` + +### 강조 텍스트 + +```json +{"type": "text", "text": {"content": "강조할 텍스트"}, "annotations": {"bold": true}} +``` + +### 인라인 코드 + +```json +{"type": "text", "text": {"content": "report_master"}, "annotations": {"code": true}} +``` + +### 불릿 리스트 + +```json +{"type": "bulleted_list_item", "bulleted_list_item": {"rich_text": [ + {"type": "text", "text": {"content": "항목 이름"}, "annotations": {"bold": true}}, + {"type": "text", "text": {"content": " — 설명 텍스트"}} +]}} +``` + +### 불릿 + 인라인 코드 조합 + +명령어/URL을 검증 항목으로 나열할 때 사용한다. + +```json +{"type": "bulleted_list_item", "bulleted_list_item": {"rich_text": [ + {"type": "text", "text": {"content": "docker-compose up -d"}, "annotations": {"code": true}}, + {"type": "text", "text": {"content": " 한 번으로 전체 스택 기동 성공"}} +]}} +``` + +### 빈 줄 + +```json +{"type": "paragraph", "paragraph": {"rich_text": []}} +``` + +### 구분선 + +```json +{"type": "divider", "divider": {}} +``` + +## 사용 가능한 annotations + +| annotation | 용도 | +|---|---| +| `bold: true` | 제목, 라벨, 강조, 경고 | +| `italic: true` | 부가 설명 | +| `bold: true` + `italic: true` | 핵심 기술 용어 첫 등장 | +| `code: true` | 파일 경로, 명령어, 인라인 코드 | +| `bold: true` + `code: true` | 코드 강조 (예: pg_dump 라벨) | +| `strikethrough: true` | 취소선 | +| `underline: true` | 밑줄 | + +--- + +## 페이지 생성 절차 + +### 단일 페이지 (WACE 직접 하위) + +1. `API-post-page`로 빈 페이지 생성 (WACE 하위) +2. `API-patch-block-children`으로 콘텐츠 추가 +3. 한 번에 최대 100블록까지 추가 가능, 초과 시 나눠서 호출 + +### 계층 페이지 (DB/페이지 하위 트리) + +1. `API-post-search`로 대상 페이지/DB 검색 → ID 확인 +2. `API-post-page`로 상위 페이지 생성 (DB 또는 페이지 하위) → ID 기록 +3. 생성된 ID를 parent로 하위 페이지 생성 → ID 기록 +4. 각 페이지에 `API-patch-block-children`으로 콘텐츠 추가 +5. 콘텐츠가 많으면 여러 번 나눠서 호출 (100블록 제한) + +병렬 생성 가능: 같은 레벨의 페이지는 동시에 생성할 수 있다. +순차 생성 필수: 상위 페이지 ID가 있어야 하위 페이지를 생성할 수 있다. + +--- + +## 페이지 구조 템플릿 + +### 기본 문서 페이지 + +``` +paragraph: 📌 핵심 요약 (bold, 기술용어 bold+italic) + +divider +heading_2: 🔹 대섹션 제목 1 + +paragraph: 📌 핵심 요약 (bold, 기술용어 bold+italic) + +heading_3: 1️⃣ 소제목 1 +bulleted_list_item: **키워드** — 설명 +bulleted_list_item: **키워드** — 설명 + +code (mermaid): 시퀀스/플로우/아키텍처/ER 다이어그램 + +heading_3: 2️⃣ 소제목 2 +paragraph: 설명 텍스트 +bulleted_list_item: 항목들 + +divider +heading_2: 🔹 대섹션 제목 2 +... + +divider +heading_2: ✅ 결론 +bulleted_list_item: 결론 1 +bulleted_list_item: 결론 2 +``` + +### Phase(개요) 페이지 + +로드맵, 프로젝트 계획 등 상위 개요 페이지에 사용한다. + +``` +paragraph: 📌 Phase 핵심 요약 (bold, 기술용어 bold+italic) +(빈 줄) + +divider +heading_2: 🔹 Phase 개요 +bulleted_list_item: **기간** — 날짜 범위 +bulleted_list_item: **학습 도구** — 도구 목록 +bulleted_list_item: **목표** — 목표 설명 +(빈 줄) + +divider +heading_2: 🔹 Sprint 목록 (또는 작업 목록) +bulleted_list_item: **S0N (날짜)** — Sprint 설명 +bulleted_list_item: **S0N (날짜)** — Sprint 설명 +bulleted_list_item: **S0N (날짜)** — Sprint 설명 +(빈 줄) + +divider +heading_2: ✅ 완료 기준 +bulleted_list_item: 검증 항목 (`코드/명령어` 인라인 코드 포함) +bulleted_list_item: 검증 항목 +``` + +### Sprint(실습) 페이지 + +기술 학습, 실습 가이드, 단계별 튜토리얼에 사용한다. + +``` +paragraph: 📌 Sprint 핵심 요약 (bold, 기술용어 bold+italic) +(빈 줄) + +divider +heading_2: 🔹 1단계: 단계 제목 +paragraph: 📌 이 단계의 목적 (bold) +(빈 줄) +bulleted_list_item: **작업명** (bold) +code (shell): 실행할 명령어 +(빈 줄) +bulleted_list_item: **다음 작업명** (bold) +code (shell/bash/yaml/docker): 파일 내용 또는 명령어 +paragraph: 부가 설명 (`명령어` code annotation 강조) +(빈 줄) + +divider +heading_2: 🔹 2단계: 단계 제목 +paragraph: 📌 이 단계의 목적 (bold) +(빈 줄) +bulleted_list_item: **작업명** (bold) +code (language): 코드/명령어 +(빈 줄) +bulleted_list_item: **핵심 개념** (bold) +bulleted_list_item: `키워드` — 설명 (code + 일반 텍스트) +bulleted_list_item: `키워드` — 설명 +(빈 줄) + +... (단계 반복) + +divider +heading_2: ✅ 완료 기준 +bulleted_list_item: `명령어/URL` 검증 항목 (code annotation) +bulleted_list_item: 검증 항목 +``` + +### 간략 페이지 (미래 작업용) + +아직 상세 내용이 없는 페이지에 사용한다. + +``` +paragraph: 📌 핵심 요약 한 줄 (bold) +(빈 줄) +paragraph: ⚠ 상세 실습 콘텐츠는 해당 Phase 진입 시 추가 예정 (bold) +``` + +--- + +## 텍스트 서식 규칙 + +| 용도 | 서식 | 예시 | +|------|------|------| +| 핵심 기술 용어 (첫 등장) | bold + italic | ***Docker***, ***Terraform*** | +| 핵심 개념/키워드 | bold | **레이어 캐싱**, **멀티스테이지 빌드** | +| 코드/명령어/경로/URL | code annotation | `docker-compose up -d`, `/api/health` | +| 코드 + 강조 | bold + code | **`pg_dump`** | +| 부가 설명 | 일반 텍스트 (괄호) | (만료 전까지 사용 가능) | +| 경고/주의 | ⚠ + bold | **⚠ 절대 main에 push 금지** | + +## 이모지 사용 규칙 + +| 이모지 | 용도 | +|--------|------| +| ✅ | 완료 기준 섹션, 장점, 완료 상태 | +| 🔹 | 대섹션 제목 (heading_2), 단계 제목 | +| 📌 | 핵심 요약, 단계 목적 설명 | +| ⚠ | 경고, 주의사항, 미완성 안내 | +| 1️⃣2️⃣3️⃣ | 소제목 번호 (heading_3) | +| 🧩🔗 | 개념 설명 소제목 | +| 📦📝⚙️🏗️🔧🚀📚🌐🔥📊📈🎯 | Sprint/페이지 icon | + +--- + +## 체크리스트 + +Notion 콘텐츠 작성 전 확인: + +**계층 페이지 생성:** +- API-post-search로 대상 페이지/DB를 먼저 검색했는가 +- 검색 결과의 object 필드로 page/database를 구분했는가 +- DB 하위 페이지는 `database_id` parent + DB의 title 속성명(예: "이름")을 사용했는가 +- 페이지 하위 페이지는 `page_id` parent + `"title"` 속성명을 사용했는가 +- 생성된 페이지 ID를 기록하여 하위 페이지/콘텐츠 추가에 사용했는가 +- icon을 JSON 문자열 형식으로 전달했는가 + +**제목 계층 구조 (필수):** +- heading_2 블록으로 대섹션(H2)을 만들었는가 +- heading_3 블록으로 소제목(H3)을 만들었는가 +- divider 블록으로 대섹션 사이를 구분했는가 +- 원본 문서의 #/##/### 계층이 heading_2/heading_3으로 정확히 반영되었는가 + +**코드 블록:** +- language 값이 Notion API 지원 목록에 있는가 (`dockerfile` → `docker`) +- 코드 블록 앞에 bulleted_list_item으로 라벨을 배치했는가 +- 코드 블록 내 주석(#)으로 각 명령어를 설명했는가 +- 명령어 실행은 `shell`, 스크립트 파일은 `bash`로 구분했는가 + +**Mermaid 다이어그램 (필수):** +- 시스템 아키텍처 → code 블록 + mermaid (graph TD + subgraph) +- API 호출 흐름 → code 블록 + mermaid (sequenceDiagram) +- 업무 프로세스 → code 블록 + mermaid (graph LR) +- DB 구조 → code 블록 + mermaid (erDiagram) +- 상태 변화 → code 블록 + mermaid (stateDiagram-v2) +- code 블록의 language가 "mermaid"로 설정되었는가 + +**마크다운 금지:** +- 텍스트에 `##`, `###` 마크다운 헤딩을 넣지 않았는가 +- 텍스트에 `---` 마크다운 구분선을 넣지 않았는가 +- annotations에 code: true를 넣고 텍스트에도 백틱을 넣지 않았는가 + +**서식 규칙:** +- 이모지 접두사를 적절히 사용했는가 (✅, 🔹, 📌, ⚠ 등) +- 핵심 기술 용어 첫 등장 시 bold + italic 조합을 사용했는가 +- 불릿 리스트에서 "**키워드** — 설명" 패턴을 따랐는가 +- 경고/안내 텍스트에 ⚠ + bold를 사용했는가 diff --git a/.cursor/skills/plan/SKILL.md b/.cursor/skills/plan/SKILL.md new file mode 100644 index 00000000..d206cba7 --- /dev/null +++ b/.cursor/skills/plan/SKILL.md @@ -0,0 +1,46 @@ +--- +name: plan +description: 리포트 기능 구현 계획서 작성 워크플로우. 현재 대화 내용을 분석하여 리포트 관련 구현 계획을 수립하고 reportdocs를 갱신. 계획 수립이나 설계가 필요할 때 사용. +--- + +# 리포트 구현 계획서 작성 워크플로우 + +## 수정 범위 제약 + +리포트 관련 기능만 계획한다. 그 외 기능은 범위 밖. + +## 절차 + +### 1. 현황 파악 +- `reportdocs/STATUS.md` 현재 진행 상태 확인 +- `reportdocs/PLAN.md` 기존 계획 확인 +- 대화에서 도출된 요구사항 정리 + +### 2. 코드베이스 분석 +Task tool의 `explore` subagent로: +- 리포트 관련 파일만 대상으로 영향 분석 +- 리포트 밖 파일에 영향이 가는지 확인 + +### 3. 계획서 작성 + +```markdown +# [리포트 기능명] 구현 계획 + +## 목표 +[1-2문장 요약] + +## 변경 파일 목록 (리포트 범위 내만) +| 파일 | 변경 유형 | 설명 | +|------|----------|------| + +## 구현 순서 +1. [ ] 단계 1 +2. [ ] 단계 2 + +## 리포트 밖 영향 여부 +- 없음 / 있음 (있으면 상세 기술) +``` + +### 4. reportdocs 갱신 +- `reportdocs/STATUS.md` 업데이트 +- `reportdocs/PLAN.md`에 새 계획 추가 diff --git a/.cursor/skills/react-component/SKILL.md b/.cursor/skills/react-component/SKILL.md new file mode 100644 index 00000000..2d7a7164 --- /dev/null +++ b/.cursor/skills/react-component/SKILL.md @@ -0,0 +1,51 @@ +--- +name: react-component +description: 리포트 React 컴포넌트 클린코드 구현/수정 워크플로우. 리포트 디자이너 컴포넌트 생성, 리팩토링, 최적화 시 사용. +--- + +# 리포트 컴포넌트 클린코드 워크플로우 + +## 수정 범위 제약 + +`frontend/components/report/` 내 파일만 수정. 그 외 컴포넌트는 수정하지 않는다. + +## 표준 컴포넌트 구조 + +```typescript +"use client"; + +// 1. 외부 라이브러리 +import React, { useState, useEffect, useCallback, useMemo } from "react"; + +// 2. 내부 유틸/컴포넌트 +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; + +// 3. 타입 +import type { ReportComponent } from "@/types/report"; + +// 4. 상수 (컴포넌트 외부) +const DEFAULT_CONFIG = { ... } as const; + +// 5. 타입 정의 (컴포넌트 외부) +interface Props { ... } + +// 6. 컴포넌트 본체 +export const MyComponent: React.FC = ({ ... }) => { + // 6-1 ~ 6-8 순서 준수 +}; +``` + +## 필수 규칙 + +- 500줄 초과 금지 → 서브 컴포넌트 분리 +- `any` 금지 → `Record` 이상 +- shadcn/ui 컴포넌트 우선 사용 +- CSS 변수 사용 (하드코딩 색상 금지) + +## 리포트 디자이너 컴포넌트 패턴 + +- `ReportDesignerContext`로 전역 상태 관리 +- 속성 패널: `designer/properties/` 디렉토리 +- 모달: `designer/modals/` 디렉토리 +- 캔버스: `designer/ReportDesignerCanvas.tsx` diff --git a/.cursor/skills/table-sql/SKILL.md b/.cursor/skills/table-sql/SKILL.md new file mode 100644 index 00000000..7dc35500 --- /dev/null +++ b/.cursor/skills/table-sql/SKILL.md @@ -0,0 +1,46 @@ +--- +name: table-sql +description: 리포트 관련 테이블 SQL 작성 가이드. 리포트 테이블 생성 DDL, 메타데이터 등록 시 사용. +--- + +# 리포트 테이블 SQL 작성 가이드 + +## 수정 범위 제약 + +리포트 관련 테이블(report_master, report_details 등)만 대상. +기존 테이블 구조는 수정하지 않는다. + +## 핵심 원칙 + +1. 모든 비즈니스 컬럼은 `VARCHAR(500)`로 통일 +2. 날짜/시간 컬럼만 `TIMESTAMP` 사용 +3. 기본 컬럼 5개 자동 포함: id, created_date, updated_date, writer, company_code +4. 3개 메타데이터 테이블 등록 필수: `table_labels`, `column_labels`, `table_type_columns` + +## 테이블 생성 DDL 템플릿 + +```sql +CREATE TABLE "테이블명" ( + "id" varchar(500) PRIMARY KEY DEFAULT gen_random_uuid()::text, + "created_date" timestamp DEFAULT now(), + "updated_date" timestamp DEFAULT now(), + "writer" varchar(500) DEFAULT NULL, + "company_code" varchar(500), + -- 사용자 정의 컬럼 + "컬럼1" varchar(500), + "컬럼2" varchar(500) +); +``` + +## 메타데이터 등록 (3개 필수) + +```sql +INSERT INTO table_labels (table_name, display_name, description, company_code) +VALUES ('테이블명', '표시명', '설명', '회사코드'); + +INSERT INTO column_labels (table_name, column_name, display_name, company_code) +VALUES ('테이블명', '컬럼명', '표시명', '회사코드'); + +INSERT INTO table_type_columns (table_name, column_name, column_type, display_order, company_code) +VALUES ('테이블명', '컬럼명', 'VARCHAR', 순서, '회사코드'); +``` diff --git a/.cursor/skills/ui-debugging/SKILL.md b/.cursor/skills/ui-debugging/SKILL.md new file mode 100644 index 00000000..6835967b --- /dev/null +++ b/.cursor/skills/ui-debugging/SKILL.md @@ -0,0 +1,55 @@ +--- +name: ui-debugging +description: 리포트 UI/UX 문제 디버깅 가이드. 리포트 화면의 레이아웃, 스크롤, 스타일 문제 진단과 해결. 리포트 UI 버그, 레이아웃, CSS 관련 이슈 시 사용. +--- + +# 리포트 UI/UX 디버깅 가이드 + +## 수정 범위 제약 + +`frontend/components/report/` 내 파일만 수정. 공통 레이아웃(AppLayout 등)은 수정하지 않는다. + +## 디버깅 공통 절차 + +1. 브라우저 개발자 도구로 문제 요소 식별 +2. Computed Style 확인 +3. 부모-자식 관계 추적 +4. 리포트 컴포넌트 내에서 최소한의 수정 +5. 반응형/다크모드에서도 검증 + +## 문제 유형별 진단 + +### 레이아웃 깨짐 +- [ ] Flexbox 부모에 `display: flex` 확인 +- [ ] 부모 체인에 명시적 높이/너비 확인 +- [ ] `overflow: hidden` 누락 여부 + +### 스크롤 문제 +- [ ] 부모 높이 확정 +- [ ] 스크롤 영역: `flex: 1, minHeight: 0, overflowY: auto` +- [ ] 중간 컨테이너: `overflow-hidden` +- 상세 패턴: [reference.md](reference.md) 참조 + +### 스타일 불일치 +- [ ] CSS 변수 사용 (하드코딩 색상 금지) +- [ ] shadcn/ui 컴포넌트 우선 +- [ ] 다크모드 호환 (`bg-background`) + +### 테이블 고정 헤더 (Sticky Header) + +```tsx +
+ + + + + 헤더 + + + + {/* 데이터 행들 */} +
+
+``` + +필수: `noWrapper`, `bg-background sticky top-0 z-10`, 고정 높이 diff --git a/.cursor/skills/ui-debugging/reference.md b/.cursor/skills/ui-debugging/reference.md new file mode 100644 index 00000000..6707240a --- /dev/null +++ b/.cursor/skills/ui-debugging/reference.md @@ -0,0 +1,70 @@ +# 스크롤 문제 상세 패턴 및 예시 + +## 패턴 A: 최상위 Fixed/Absolute 컨테이너 + +```tsx +
+
+
헤더
+
+ +
+
+
+``` + +## 패턴 B: 중첩된 Flex 컨테이너 + +```tsx +
+
사이드바
+
캔버스
+
+ +
+
+``` + +## 패턴 C: 스크롤 가능 영역 + +```tsx +
+
헤더
+
+ +
+
+``` + +## 일반적인 실수 + +### 부모 높이 미확정 +```tsx +// Bad +
+// Good +
+``` + +### minHeight: 0 누락 +```tsx +// Bad +
{/* 스크롤 안 됨 */}
+// Good +
{/* 스크롤 됨 */}
+``` + +## 최종 구조 + +``` +페이지 (fixed inset-0) +└─ flex flex-col h-full + ├─ 헤더 (고정) + └─ 컨테이너 (flex-1 overflow-hidden) + └─ 에디터 (height: 100%, overflow: hidden) + └─ flex row + └─ 패널 (display: flex, flexDirection: column) + └─ 패널 내부 (height: 100%) + ├─ 헤더 (flexShrink: 0, height: 64px) + └─ 스크롤 (flex: 1, minHeight: 0, overflowY: auto) +``` diff --git a/.cursor/skills/web-verify/SKILL.md b/.cursor/skills/web-verify/SKILL.md new file mode 100644 index 00000000..17201ee1 --- /dev/null +++ b/.cursor/skills/web-verify/SKILL.md @@ -0,0 +1,70 @@ +--- +name: web-verify +description: WACE PLM UI 검증 워크플로우. 화면 구현 후 스크린샷으로 시각적 확인이 필요할 때 사용. +disable-model-invocation: true +--- + +# UI 검증 워크플로우 + +## 로그인 정보 (자동 적용) + +- URL: http://localhost:9771 +- 아이디: wace +- 비밀번호: qlalfqjsgh11 + +## 절차 + +1. 로컬 서버 상태 확인 (9771, 9090) +2. browser-use subagent로 브라우저 실행 +3. 위 계정으로 자동 로그인 → 요청된 화면으로 이동 +4. 스크린샷 캡처 및 분석 + +## 화면별 접근 방법 + +### 리포트 관리 페이지 +1. 좌측 메뉴 > 화면관리 > 리포트 관리 클릭 +2. URL: `/admin/screenMng/reportList` + +### 리포트 디자이너 진입 +1. 리포트 관리 페이지에서 리포트 행의 "수정" 버튼(연필 아이콘) 클릭 +2. 또는 리포트명 텍스트를 직접 클릭 +3. URL: `/admin/screenMng/reportList/designer/{reportId}` + +### 리포트 디자이너 — 컴포넌트 설정 모달 열기 +캔버스 내부의 컴포넌트는 일반 클릭으로 선택되지 않을 수 있음. 아래 방법을 순서대로 시도할 것: + +1. **방법 1: 캔버스 내 컴포넌트 더블클릭** + - 캔버스 영역에서 텍스트/테이블 등 컴포넌트 위치를 더블클릭 + - 모달이 열리면 상단에 "{타입} 설정" 제목이 표시됨 (예: "텍스트 설정") + - 탭: 기능 설정 / 데이터 소스 / 미리보기 + +2. **방법 2: 브라우저 콘솔에서 직접 모달 열기** (방법 1 실패 시) + - 브라우저 콘솔에서 컴포넌트 ID를 찾아 `openComponentModal` 호출 + - 캔버스 내 요소의 `data-component-id` 속성 확인 + +3. **방법 3: 우측 패널 활용** + - 캔버스에서 컴포넌트를 단일 클릭하면 우측 패널에 "스타일 편집" 표시 + - 우측 패널 상단의 컴포넌트명 확인으로 선택 여부 판단 + +### 리포트 디자이너 — 모달 탭 구조 +- **기능 설정**: 데이터 바인딩(쿼리 Select + 필드 Select) + 컴포넌트별 설정 +- **데이터 소스**: 비주얼 데이터 소스 빌더 (마스터-디테일 테이블 설정) +- **미리보기**: 컴포넌트 미리보기 + +## 검증 항목 + +### 리포트 목록 +- 테이블 데이터 로딩 +- CRUD 버튼 동작 +- 검색/필터 + +### 리포트 디자이너 +- 캔버스 렌더링 +- 컴포넌트 더블클릭 → 설정 모달 열기 +- 데이터 소스 탭: 마스터 테이블 선택, 컬럼 체크, 디테일 추가, FK 자동 감지 +- 속성 패널 +- 프리뷰 모달 + +### 공통 +- 스크롤 정상 +- 콘솔 에러 없음 diff --git a/backend-node/src/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts index d334e46e..c092ffa5 100644 --- a/backend-node/src/controllers/reportController.ts +++ b/backend-node/src/controllers/reportController.ts @@ -1,15 +1,14 @@ -/** - * 리포트 관리 컨트롤러 - */ - -import { Request, Response, NextFunction } from "express"; +import { Response, NextFunction } from "express"; import reportService from "../services/reportService"; import { CreateReportRequest, UpdateReportRequest, SaveLayoutRequest, CreateTemplateRequest, + GetReportsParams, } from "../types/report"; +import { AuthenticatedRequest } from "../types/auth"; +import { logger } from "../utils/logger"; import path from "path"; import fs from "fs"; import { @@ -35,92 +34,91 @@ import { import { WatermarkConfig } from "../types/report"; import bwipjs from "bwip-js"; +function getUserInfo(req: AuthenticatedRequest) { + return { + userId: req.user?.userId || "SYSTEM", + companyCode: req.user?.companyCode || "*", + }; +} + export class ReportController { - /** - * 리포트 목록 조회 - * GET /api/admin/reports - */ - async getReports(req: Request, res: Response, next: NextFunction) { + async getReports(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { + const { companyCode } = getUserInfo(req); const { - page = "1", - limit = "20", - searchText = "", - reportType = "", - useYn = "Y", - sortBy = "created_at", - sortOrder = "DESC", + page = "1", limit = "20", searchText = "", searchField, + startDate, endDate, 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, + searchField: searchField as GetReportsParams["searchField"], + startDate: startDate as string | undefined, + endDate: endDate as string | undefined, reportType: reportType as string, useYn: useYn as string, sortBy: sortBy as string, sortOrder: sortOrder as "ASC" | "DESC", - }); + }, companyCode); - return res.json({ - success: true, - data: result, - }); + 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) { + async getReportById(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { + const { companyCode } = getUserInfo(req); const { reportId } = req.params; - const report = await reportService.getReportById(reportId); + const report = await reportService.getReportById(reportId, companyCode); if (!report) { - return res.status(404).json({ - success: false, - message: "리포트를 찾을 수 없습니다.", - }); + return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." }); } - return res.json({ - success: true, - data: report, - }); + return res.json({ success: true, data: report }); } catch (error) { return next(error); } } - /** - * 리포트 생성 - * POST /api/admin/reports - */ - async createReport(req: Request, res: Response, next: NextFunction) { + async getReportsByMenuObjid(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { - const data: CreateReportRequest = req.body; - const userId = (req as any).user?.userId || "SYSTEM"; + const { companyCode } = getUserInfo(req); + const { menuObjid } = req.params; + const menuObjidNum = parseInt(menuObjid, 10); - // 필수 필드 검증 - if (!data.reportNameKor || !data.reportType) { - return res.status(400).json({ - success: false, - message: "리포트명과 리포트 타입은 필수입니다.", - }); + if (isNaN(menuObjidNum)) { + return res.status(400).json({ success: false, message: "menuObjid는 숫자여야 합니다." }); } + const result = await reportService.getReportsByMenuObjid(menuObjidNum, companyCode); + return res.json({ success: true, data: result }); + } catch (error) { + return next(error); + } + } + + async createReport(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const { userId, companyCode } = getUserInfo(req); + const data: CreateReportRequest = req.body; + + if (!data.reportNameKor || !data.reportType) { + return res.status(400).json({ success: false, message: "리포트명과 리포트 타입은 필수입니다." }); + } + + data.companyCode = companyCode; const reportId = await reportService.createReport(data, userId); return res.status(201).json({ success: true, - data: { - reportId, - }, + data: { reportId }, message: "리포트가 생성되었습니다.", }); } catch (error) { @@ -128,83 +126,56 @@ export class ReportController { } } - /** - * 리포트 수정 - * PUT /api/admin/reports/:reportId - */ - async updateReport(req: Request, res: Response, next: NextFunction) { + async updateReport(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { + const { userId, companyCode } = getUserInfo(req); 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); + const success = await reportService.updateReport(reportId, data, userId, companyCode); if (!success) { - return res.status(400).json({ - success: false, - message: "수정할 내용이 없습니다.", - }); + return res.status(400).json({ success: false, message: "수정할 내용이 없습니다." }); } - return res.json({ - success: true, - 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) { + async deleteReport(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { + const { companyCode } = getUserInfo(req); const { reportId } = req.params; - const success = await reportService.deleteReport(reportId); + const success = await reportService.deleteReport(reportId, companyCode); if (!success) { - return res.status(404).json({ - success: false, - message: "리포트를 찾을 수 없습니다.", - }); + return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." }); } - return res.json({ - success: true, - 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) { + async copyReport(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { + const { userId, companyCode } = getUserInfo(req); const { reportId } = req.params; - const userId = (req as any).user?.userId || "SYSTEM"; + const { newName } = req.body; - const newReportId = await reportService.copyReport(reportId, userId); + const newReportId = await reportService.copyReport(reportId, userId, companyCode, newName); if (!newReportId) { - return res.status(404).json({ - success: false, - message: "리포트를 찾을 수 없습니다.", - }); + return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." }); } return res.status(201).json({ success: true, - data: { - reportId: newReportId, - }, + data: { reportId: newReportId }, message: "리포트가 복사되었습니다.", }); } catch (error) { @@ -212,132 +183,92 @@ export class ReportController { } } - /** - * 레이아웃 조회 - * GET /api/admin/reports/:reportId/layout - */ - async getLayout(req: Request, res: Response, next: NextFunction) { + async getLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { + const { companyCode } = getUserInfo(req); const { reportId } = req.params; - const layout = await reportService.getLayout(reportId); + const layout = await reportService.getLayout(reportId, companyCode); if (!layout) { - return res.status(404).json({ - success: false, - message: "레이아웃을 찾을 수 없습니다.", - }); + return res.status(404).json({ success: false, message: "레이아웃을 찾을 수 없습니다." }); } - // components 컬럼에서 JSON 파싱 - const parsedComponents = layout.components - ? JSON.parse(layout.components) - : null; - + const storedData = layout.components; let layoutData; - // 새 구조 (layoutConfig.pages)인지 확인 + if ( - parsedComponents && - parsedComponents.pages && - Array.isArray(parsedComponents.pages) + storedData && + typeof storedData === "object" && + !Array.isArray(storedData) && + Array.isArray((storedData as Record).pages) ) { - // pages 배열을 직접 포함하여 반환 + const parsed = storedData as Record; layoutData = { ...layout, - pages: parsedComponents.pages, - components: [], // 호환성을 위해 빈 배열 + pages: parsed.pages, + watermark: parsed.watermark, + components: storedData, }; } else { - // 기존 구조: components 배열 - layoutData = { - ...layout, - components: parsedComponents || [], - }; + layoutData = { ...layout, components: storedData || [] }; } - return res.json({ - success: true, - data: layoutData, - }); + 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) { + async saveLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { + const { userId, companyCode } = getUserInfo(req); const { reportId } = req.params; const data: SaveLayoutRequest = req.body; - const userId = (req as any).user?.userId || "SYSTEM"; - // 필수 필드 검증 (페이지 기반 구조) - if ( - !data.layoutConfig || - !data.layoutConfig.pages || - data.layoutConfig.pages.length === 0 - ) { - return res.status(400).json({ - success: false, - message: "레이아웃 설정이 필요합니다.", - }); + if (!data.layoutConfig?.pages?.length) { + return res.status(400).json({ success: false, message: "레이아웃 설정이 필요합니다." }); } - await reportService.saveLayout(reportId, data, userId); - - return res.json({ - success: true, - message: "레이아웃이 저장되었습니다.", - }); + await reportService.saveLayout(reportId, data, userId, companyCode); + return res.json({ success: true, message: "레이아웃이 저장되었습니다." }); } catch (error) { return next(error); } } - /** - * 템플릿 목록 조회 - * GET /api/admin/reports/templates - */ - async getTemplates(req: Request, res: Response, next: NextFunction) { + async getTemplates(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const templates = await reportService.getTemplates(); - - return res.json({ - success: true, - data: templates, - }); + 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) { + async getCategories(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { - const data: CreateTemplateRequest = req.body; - const userId = (req as any).user?.userId || "SYSTEM"; + const categories = await reportService.getCategories(); + return res.json({ success: true, data: categories }); + } catch (error) { + return next(error); + } + } + + async createTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const { userId } = getUserInfo(req); + const data: CreateTemplateRequest = req.body; - // 필수 필드 검증 if (!data.templateNameKor || !data.templateType) { - return res.status(400).json({ - success: false, - message: "템플릿명과 템플릿 타입은 필수입니다.", - }); + return res.status(400).json({ success: false, message: "템플릿명과 템플릿 타입은 필수입니다." }); } const templateId = await reportService.createTemplate(data, userId); return res.status(201).json({ success: true, - data: { - templateId, - }, + data: { templateId }, message: "템플릿이 생성되었습니다.", }); } catch (error) { @@ -345,37 +276,23 @@ export class ReportController { } } - /** - * 현재 리포트를 템플릿으로 저장 - * POST /api/admin/reports/:reportId/save-as-template - */ - async saveAsTemplate(req: Request, res: Response, next: NextFunction) { + async saveAsTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { + const { userId } = getUserInfo(req); 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: "템플릿명은 필수입니다.", - }); + return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." }); } const templateId = await reportService.saveAsTemplate( - reportId, - templateNameKor, - templateNameEng, - description, - userId + reportId, templateNameKor, templateNameEng, description, userId ); return res.status(201).json({ success: true, - data: { - templateId, - }, + data: { templateId }, message: "템플릿이 저장되었습니다.", }); } catch (error) { @@ -383,39 +300,20 @@ export class ReportController { } } - /** - * 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요) - * POST /api/admin/reports/templates/create-from-layout - */ - async createTemplateFromLayout( - req: Request, - res: Response, - next: NextFunction - ) { + async createTemplateFromLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { + const { userId } = getUserInfo(req); const { - templateNameKor, - templateNameEng, - templateType, - description, - layoutConfig, - defaultQueries = [], + 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: "템플릿명은 필수입니다.", - }); + return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." }); } if (!layoutConfig) { - return res.status(400).json({ - success: false, - message: "레이아웃 설정은 필수입니다.", - }); + return res.status(400).json({ success: false, message: "레이아웃 설정은 필수입니다." }); } const templateId = await reportService.createTemplateFromLayout( @@ -440,78 +338,47 @@ export class ReportController { } } - /** - * 템플릿 삭제 - * DELETE /api/admin/reports/templates/:templateId - */ - async deleteTemplate(req: Request, res: Response, next: NextFunction) { + async deleteTemplate(req: AuthenticatedRequest, 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.status(404).json({ success: false, message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다." }); } - return res.json({ - success: true, - 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) { + async executeQuery(req: AuthenticatedRequest, 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 + 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 || "쿼리 실행에 실패했습니다.", - }); + return res.json({ success: true, data: result }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다."; + return res.status(400).json({ success: false, message }); } } - /** - * 외부 DB 연결 목록 조회 (활성화된 것만) - * GET /api/admin/reports/external-connections - */ - async getExternalConnections( - req: Request, - res: Response, - next: NextFunction - ) { + async getExternalConnections(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { + const { companyCode } = getUserInfo(req); const { ExternalDbConnectionService } = await import( "../services/externalDbConnectionService" ); const result = await ExternalDbConnectionService.getConnections({ is_active: "Y", - company_code: req.body.companyCode || "", + company_code: companyCode, }); return res.json(result); @@ -520,52 +387,34 @@ export class ReportController { } } - /** - * 이미지 파일 업로드 - * POST /api/admin/reports/upload-image - */ - async uploadImage(req: Request, res: Response, next: NextFunction) { + async uploadImage(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { if (!req.file) { - return res.status(400).json({ - success: false, - message: "이미지 파일이 필요합니다.", - }); + return res.status(400).json({ success: false, message: "이미지 파일이 필요합니다." }); } - const companyCode = req.body.companyCode || "SYSTEM"; + const { companyCode } = getUserInfo(req); const file = req.file; - // 파일 저장 경로 생성 - const uploadDir = path.join( - process.cwd(), - "uploads", - `company_${companyCode}`, - "reports" - ); + 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, + fileName, fileUrl, originalName: file.originalname, size: file.size, mimeType: file.mimetype, @@ -576,11 +425,7 @@ export class ReportController { } } - /** - * 컴포넌트 데이터를 WORD(DOCX)로 변환 - * POST /api/admin/reports/export-word - */ - async exportToWord(req: Request, res: Response, next: NextFunction) { + async exportToWord(req: AuthenticatedRequest, res: Response, next: NextFunction) { try { const { layoutConfig, queryResults, fileName = "리포트" } = req.body; @@ -591,22 +436,15 @@ export class ReportController { }); } - // mm를 twip으로 변환 const mmToTwip = (mm: number) => convertMillimetersToTwip(mm); - - // 프론트엔드와 동일한 MM_TO_PX 상수 (캔버스에서 mm를 px로 변환할 때 사용하는 값) - const MM_TO_PX = 4; - // 1mm = 56.692913386 twip (docx 라이브러리 기준) - // px를 twip으로 변환: px -> mm -> twip + const MM_TO_PX = 4; // 프론트엔드와 동일, 1mm = 56.692913386 twip (docx) const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386); - // 쿼리 결과 맵 const queryResultsMap: Record< string, { fields: string[]; rows: Record[] } > = queryResults || {}; - // 컴포넌트 값 가져오기 const getComponentValue = (component: any): string => { if (component.queryId && component.fieldName) { const queryResult = queryResultsMap[component.queryId]; @@ -621,11 +459,9 @@ export class ReportController { return component.defaultValue || ""; }; - // px → half-point 변환 (1px = 0.75pt, Word는 half-pt 단위 사용) - // px * 0.75 * 2 = px * 1.5 + // px → half-point (1px = 0.75pt, px * 1.5) const pxToHalfPt = (px: number) => Math.round(px * 1.5); - // 셀 내용 생성 헬퍼 함수 (가로 배치용) const createCellContent = ( component: any, displayValue: string, @@ -1557,7 +1393,7 @@ export class ReportController { const base64 = png.toString("base64"); return `data:image/png;base64,${base64}`; } catch (error) { - console.error("바코드 생성 오류:", error); + logger.error("바코드 생성 오류:", error); return null; } }; @@ -1891,7 +1727,7 @@ export class ReportController { children.push(paragraph); lastBottomY = adjustedY + component.height; } catch (imgError) { - console.error("이미지 처리 오류:", imgError); + logger.error("이미지 처리 오류:", imgError); } } @@ -2005,7 +1841,7 @@ export class ReportController { }); children.push(paragraph); } catch (imgError) { - console.error("서명 이미지 오류:", imgError); + logger.error("서명 이미지 오류:", imgError); textRuns.push( new TextRun({ text: "_".repeat(20), @@ -2083,7 +1919,7 @@ export class ReportController { }); children.push(paragraph); } catch (imgError) { - console.error("도장 이미지 오류:", imgError); + logger.error("도장 이미지 오류:", imgError); textRuns.push( new TextRun({ text: "(인)", @@ -2886,7 +2722,7 @@ export class ReportController { }) ); } catch (imgError) { - console.error("바코드 이미지 오류:", imgError); + logger.error("바코드 이미지 오류:", imgError); // 바코드 이미지 생성 실패 시 텍스트로 대체 const barcodeValue = component.barcodeValue || "BARCODE"; children.push( @@ -3164,13 +3000,57 @@ export class ReportController { return res.send(docxBuffer); } catch (error: any) { - console.error("WORD 변환 오류:", error); + logger.error("WORD 변환 오류:", error); return res.status(500).json({ success: false, message: error.message || "WORD 변환에 실패했습니다.", }); } } + + // ─── 비주얼 쿼리 빌더 API ───────────────────────────────────────────────────── + + async getSchemaTables(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const tables = await reportService.getSchemaTables(); + return res.json({ success: true, data: tables }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "테이블 목록 조회에 실패했습니다."; + logger.error("스키마 테이블 조회 오류:", { error: message }); + return res.status(500).json({ success: false, message }); + } + } + + async getSchemaTableColumns(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const { tableName } = req.params; + if (!tableName) { + return res.status(400).json({ success: false, message: "테이블명이 필요합니다." }); + } + const columns = await reportService.getSchemaTableColumns(tableName); + return res.json({ success: true, data: columns }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "컬럼 목록 조회에 실패했습니다."; + logger.error("테이블 컬럼 조회 오류:", { error: message }); + return res.status(500).json({ success: false, message }); + } + } + + async previewVisualQuery(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const { visualQuery } = req.body; + if (!visualQuery || !visualQuery.tableName) { + return res.status(400).json({ success: false, message: "visualQuery 정보가 필요합니다." }); + } + const result = await reportService.executeVisualQuery(visualQuery); + const generatedSql = reportService.buildVisualQuerySql(visualQuery); + return res.json({ success: true, data: { ...result, sql: generatedSql } }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다."; + logger.error("비주얼 쿼리 미리보기 오류:", { error: message }); + return res.status(500).json({ success: false, message }); + } + } } export default new ReportController(); diff --git a/backend-node/src/routes/reportRoutes.ts b/backend-node/src/routes/reportRoutes.ts index bb644fef..7151bfb7 100644 --- a/backend-node/src/routes/reportRoutes.ts +++ b/backend-node/src/routes/reportRoutes.ts @@ -43,6 +43,11 @@ router.get("/templates", (req, res, next) => router.post("/templates", (req, res, next) => reportController.createTemplate(req, res, next) ); + +// 카테고리(report_type) 목록 조회 +router.get("/categories", (req, res, next) => + reportController.getCategories(req, res, next) +); // 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요) router.post("/templates/create-from-layout", (req, res, next) => reportController.createTemplateFromLayout(req, res, next) @@ -61,6 +66,17 @@ router.post("/export-word", (req, res, next) => reportController.exportToWord(req, res, next) ); +// 비주얼 쿼리 빌더 — 스키마 조회 (/:reportId 패턴보다 반드시 먼저 등록) +router.get("/schema/tables", (req, res, next) => + reportController.getSchemaTables(req, res, next) +); +router.get("/schema/tables/:tableName/columns", (req, res, next) => + reportController.getSchemaTableColumns(req, res, next) +); +router.post("/schema/preview", (req, res, next) => + reportController.previewVisualQuery(req, res, next) +); + // 리포트 목록 router.get("/", (req, res, next) => reportController.getReports(req, res, next) @@ -71,6 +87,11 @@ router.post("/", (req, res, next) => reportController.createReport(req, res, next) ); +// 메뉴별 리포트 목록 (/:reportId 보다 반드시 먼저 등록) +router.get("/by-menu/:menuObjid", (req, res, next) => + reportController.getReportsByMenuObjid(req, res, next) +); + // 리포트 복사 (구체적인 경로를 먼저 배치) router.post("/:reportId/copy", (req, res, next) => reportController.copyReport(req, res, next) diff --git a/backend-node/src/services/reportService.ts b/backend-node/src/services/reportService.ts index 6e2df6b2..ed87075e 100644 --- a/backend-node/src/services/reportService.ts +++ b/backend-node/src/services/reportService.ts @@ -1,7 +1,3 @@ -/** - * 리포트 관리 서비스 - */ - import { v4 as uuidv4 } from "uuid"; import { query, queryOne, transaction } from "../database/db"; import { @@ -17,16 +13,87 @@ import { SaveLayoutRequest, GetTemplatesResponse, CreateTemplateRequest, + VisualQuery, } from "../types/report"; import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; import { ExternalDbConnectionService } from "./externalDbConnectionService"; +import { logger } from "../utils/logger"; + +const REPORT_TYPE_LABELS: Record = { + ORDER: "발주서", + INVOICE: "청구서", + STATEMENT: "거래명세서", + RECEIPT: "영수증", + BASIC: "기본", +}; + +const ALLOWED_SORT_COLUMNS = [ + "created_at", + "updated_at", + "report_name_kor", + "report_name_eng", + "report_type", + "use_yn", +] as const; + +const ALLOWED_SORT_ORDERS = ["ASC", "DESC"] as const; + +const DEFAULT_MARGINS = { top: 20, bottom: 20, left: 20, right: 20 }; +const DEFAULT_CANVAS_WIDTH = 210; +const DEFAULT_CANVAS_HEIGHT = 297; + +function generateReportId(): string { + return `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`; +} + +function generateLayoutId(): string { + return `LAY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; +} + +function generateQueryId(): string { + return `QRY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; +} + +function generateTemplateId(): string { + return `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; +} + +function findTypeCodesByLabel(searchText: string): string[] { + const lower = searchText.toLowerCase(); + return Object.entries(REPORT_TYPE_LABELS) + .filter(([code, label]) => label.includes(searchText) || code.toLowerCase().includes(lower)) + .map(([code]) => code); +} + +function parseJsonComponents(raw: string | Record | null): Record | null { + if (raw === null || raw === undefined) return null; + if (typeof raw === "string") { + try { + return JSON.parse(raw); + } catch { + return null; + } + } + return raw; +} + +function sanitizeSortBy(sortBy: string): string { + if ((ALLOWED_SORT_COLUMNS as readonly string[]).includes(sortBy)) { + return sortBy; + } + return "created_at"; +} + +function sanitizeSortOrder(sortOrder: string): "ASC" | "DESC" { + const upper = sortOrder.toUpperCase(); + if ((ALLOWED_SORT_ORDERS as readonly string[]).includes(upper)) { + return upper as "ASC" | "DESC"; + } + return "DESC"; +} export class ReportService { - /** - * SQL 쿼리 검증 (SELECT만 허용) - */ private validateQuerySafety(sql: string): void { - // 위험한 SQL 명령어 목록 const dangerousKeywords = [ "DELETE", "DROP", @@ -44,12 +111,9 @@ export class ReportService { "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( @@ -58,14 +122,12 @@ export class ReportService { } } - // SELECT 쿼리인지 확인 if (!upperSql.startsWith("SELECT") && !upperSql.startsWith("WITH")) { throw new Error( "SELECT 쿼리만 허용됩니다. 데이터 조회 용도로만 사용할 수 있습니다." ); } - // 세미콜론으로 구분된 여러 쿼리 방지 const semicolonCount = (sql.match(/;/g) || []).length; if ( semicolonCount > 1 || @@ -77,14 +139,14 @@ export class ReportService { } } - /** - * 리포트 목록 조회 - */ - async getReports(params: GetReportsParams): Promise { + async getReports(params: GetReportsParams, companyCode: string): Promise { const { page = 1, limit = 20, searchText = "", + searchField, + startDate, + endDate, reportType = "", useYn = "Y", sortBy = "created_at", @@ -92,784 +154,758 @@ export class ReportService { } = params; const offset = (page - 1) * limit; + const safeSortBy = sanitizeSortBy(sortBy); + const safeSortOrder = sanitizeSortOrder(sortOrder); - // WHERE 조건 동적 생성 const conditions: string[] = []; - const values: any[] = []; + const values: (string | number)[] = []; let paramIndex = 1; + this.applyCompanyCodeFilter(conditions, values, paramIndex, companyCode, "rm"); + paramIndex = values.length + 1; + if (useYn) { - conditions.push(`use_yn = $${paramIndex++}`); + conditions.push(`rm.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++; - } + paramIndex = this.applySearchConditions( + conditions, values, paramIndex, searchText, searchField, startDate, endDate + ); if (reportType) { - conditions.push(`report_type = $${paramIndex++}`); + conditions.push(`rm.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 countResult = await queryOne<{ total: string }>( + `SELECT COUNT(*) as total FROM report_master rm ${whereClause}`, + 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 + rm.report_id, rm.report_name_kor, rm.report_name_eng, + rm.template_id, rt.template_name_kor AS template_name, + rm.report_type, rm.company_code, rm.description, rm.use_yn, + rm.created_at, rm.created_by, rm.updated_at, rm.updated_by + FROM report_master rm + LEFT JOIN report_template rt ON rm.template_id = rt.template_id ${whereClause} - ORDER BY ${sortBy} ${sortOrder} + ORDER BY rm.${safeSortBy} ${safeSortOrder} LIMIT $${paramIndex++} OFFSET $${paramIndex} `; - const items = await query(listQuery, [ - ...values, - limit, - offset, - ]); + const items = await query(listQuery, [...values, limit, offset]); + + const { typeSummary, allTypes, recentActivity, recentTotal } = + await this.getReportStatistics(companyCode); return { - items, - total, - page, - limit, + items, total, page, limit, + typeSummary, allTypes, recentActivity, recentTotal, }; } - /** - * 리포트 상세 조회 - */ - 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]); + private applyCompanyCodeFilter( + conditions: string[], + values: (string | number)[], + paramIndex: number, + companyCode: string, + alias: string + ): void { + if (companyCode !== "*") { + conditions.push(`${alias}.company_code = $${paramIndex}`); + values.push(companyCode); + } + } - if (!report) { - return null; + private applySearchConditions( + conditions: string[], + values: (string | number)[], + paramIndex: number, + searchText: string, + searchField?: string, + startDate?: string, + endDate?: string + ): number { + const isDateRangeSearch = + (searchField === "created_at" || searchField === "updated_at") && startDate && endDate; + + if (isDateRangeSearch) { + const dateColumn = searchField === "created_at" + ? "rm.created_at" + : "COALESCE(rm.updated_at, rm.created_at)"; + conditions.push(`${dateColumn} >= $${paramIndex}::date`); + values.push(startDate!); + paramIndex++; + conditions.push(`${dateColumn} < ($${paramIndex}::date + INTERVAL '1 day')`); + values.push(endDate!); + paramIndex++; + } else if (searchText) { + paramIndex = this.applyTextSearch(conditions, values, paramIndex, searchText, searchField); } - // 레이아웃 조회 - 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]); - - // 메뉴 매핑 조회 - const menuMappingQuery = ` - SELECT menu_objid - FROM report_menu_mapping - WHERE report_id = $1 - ORDER BY created_at - `; - const menuMappings = await query<{ menu_objid: number }>(menuMappingQuery, [ - reportId, - ]); - const menuObjids = menuMappings?.map((m) => Number(m.menu_objid)) || []; - - return { - report, - layout, - queries: queries || [], - menuObjids, - }; + return paramIndex; + } + + private applyTextSearch( + conditions: string[], + values: (string | number)[], + paramIndex: number, + searchText: string, + searchField?: string + ): number { + if (searchField === "created_by") { + conditions.push(`rm.created_by LIKE $${paramIndex}`); + values.push(`%${searchText}%`); + paramIndex++; + } else if (searchField === "report_type") { + const matchedCodes = findTypeCodesByLabel(searchText); + if (matchedCodes.length > 0) { + const placeholders = matchedCodes.map(() => `$${paramIndex++}`).join(", "); + conditions.push(`rm.report_type IN (${placeholders})`); + values.push(...matchedCodes); + } else { + conditions.push(`rm.report_type LIKE $${paramIndex}`); + values.push(`%${searchText}%`); + paramIndex++; + } + } else if (searchField === "updated_at") { + conditions.push(`CAST(rm.updated_at AS TEXT) LIKE $${paramIndex}`); + values.push(`%${searchText}%`); + paramIndex++; + } else { + conditions.push( + `(rm.report_name_kor LIKE $${paramIndex} OR rm.report_name_eng LIKE $${paramIndex})` + ); + values.push(`%${searchText}%`); + paramIndex++; + } + return paramIndex; + } + + private async getReportStatistics(companyCode: string) { + const companyFilter = companyCode !== "*" ? " AND company_code = $1" : ""; + const companyParams = companyCode !== "*" ? [companyCode] : []; + + const typeSummaryRows = await query<{ report_type: string; count: string }>( + `SELECT report_type, COUNT(*) as count + FROM report_master + WHERE use_yn = 'Y' AND report_type IS NOT NULL AND report_type != ''${companyFilter} + GROUP BY report_type + ORDER BY count DESC`, + companyParams + ); + const typeSummary = typeSummaryRows.map((r) => ({ + type: r.report_type, + count: parseInt(r.count, 10), + })); + const allTypes = typeSummary.map((t) => t.type).sort(); + + const recentActivityRows = await query<{ date_label: string; date_raw: string; count: string }>( + `SELECT TO_CHAR(COALESCE(updated_at, created_at), 'MM/DD') AS date_label, + MAX(COALESCE(updated_at, created_at)) AS date_raw, + COUNT(*) AS count + FROM report_master + WHERE use_yn = 'Y' + AND COALESCE(updated_at, created_at) >= NOW() - INTERVAL '30 days'${companyFilter} + GROUP BY date_label + ORDER BY count DESC, date_raw DESC + LIMIT 3`, + companyParams + ); + const recentActivity = recentActivityRows + .map((r) => ({ date: r.date_label, count: parseInt(r.count, 10) })) + .sort((a, b) => a.count - b.count); + + const recentCountResult = await queryOne<{ count: string }>( + `SELECT COUNT(*) AS count FROM report_master + WHERE use_yn = 'Y' + AND COALESCE(updated_at, created_at) >= NOW() - INTERVAL '30 days'${companyFilter}`, + companyParams + ); + const recentTotal = parseInt(recentCountResult?.count || "0", 10); + + return { typeSummary, allTypes, recentActivity, recentTotal }; + } + + async getReportById(reportId: string, companyCode: string): Promise { + const companyCondition = companyCode !== "*" ? " AND company_code = $2" : ""; + const reportParams = companyCode !== "*" ? [reportId, companyCode] : [reportId]; + + const report = await queryOne( + `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${companyCondition}`, + reportParams + ); + + if (!report) return null; + + const layout = await this.getLayoutInternal(reportId); + + const queries = await query( + `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`, + [reportId] + ); + + const menuMappings = await query<{ menu_objid: number }>( + `SELECT menu_objid FROM report_menu_mapping WHERE report_id = $1 ORDER BY created_at`, + [reportId] + ); + const menuObjids = menuMappings?.map((m) => Number(m.menu_objid)) || []; + + return { report, layout, queries: queries || [], menuObjids }; } - /** - * 리포트 생성 - */ async createReport( data: CreateReportRequest, userId: string ): Promise { - const reportId = `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + const reportId = generateReportId(); 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( + `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)`, + [ + reportId, data.reportNameKor, data.reportNameEng || null, + data.templateId || null, data.reportType, + data.companyCode || null, data.description || null, userId, + ] + ); - 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, - ]); - } + await this.createLayoutFromTemplate(client, data.templateId, reportId, userId); } return reportId; }); } - /** - * 리포트 수정 - */ + private async createLayoutFromTemplate( + client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, + templateId: string, + reportId: string, + userId: string + ): Promise { + const template = await client.query( + `SELECT layout_config FROM report_template WHERE template_id = $1`, + [templateId] + ); + + if (template.rows.length === 0 || !template.rows[0].layout_config) return; + + const layoutConfig = JSON.parse(template.rows[0].layout_config as string); + + await client.query( + `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)`, + [ + generateLayoutId(), reportId, + layoutConfig.width || DEFAULT_CANVAS_WIDTH, + layoutConfig.height || DEFAULT_CANVAS_HEIGHT, + layoutConfig.orientation || "portrait", + DEFAULT_MARGINS.top, DEFAULT_MARGINS.bottom, + DEFAULT_MARGINS.left, DEFAULT_MARGINS.right, + JSON.stringify([]), userId, + ] + ); + } + async updateReport( reportId: string, data: UpdateReportRequest, - userId: string + userId: string, + companyCode: string ): Promise { const setClauses: string[] = []; - const values: any[] = []; + const values: (string | number | null)[] = []; let paramIndex = 1; - if (data.reportNameKor !== undefined) { - setClauses.push(`report_name_kor = $${paramIndex++}`); - values.push(data.reportNameKor); + const fieldMap: Array<[keyof UpdateReportRequest, string]> = [ + ["reportNameKor", "report_name_kor"], + ["reportNameEng", "report_name_eng"], + ["reportType", "report_type"], + ["description", "description"], + ["useYn", "use_yn"], + ]; + + for (const [key, column] of fieldMap) { + if (data[key] !== undefined) { + setClauses.push(`${column} = $${paramIndex++}`); + values.push(data[key] as string); + } } - 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; - } + if (setClauses.length === 0) return false; setClauses.push(`updated_at = CURRENT_TIMESTAMP`); setClauses.push(`updated_by = $${paramIndex++}`); values.push(userId); values.push(reportId); + let whereClause = `WHERE report_id = $${paramIndex}`; - const updateQuery = ` - UPDATE report_master - SET ${setClauses.join(", ")} - WHERE report_id = $${paramIndex} - `; + if (companyCode !== "*") { + paramIndex++; + values.push(companyCode); + whereClause += ` AND company_code = $${paramIndex}`; + } - const result = await query(updateQuery, values); + await query(`UPDATE report_master SET ${setClauses.join(", ")} ${whereClause}`, values); return true; } - /** - * 리포트 삭제 - */ - async deleteReport(reportId: string): Promise { + async deleteReport(reportId: string, companyCode: string): Promise { return transaction(async (client) => { - // 쿼리 삭제 (CASCADE로 자동 삭제되지만 명시적으로) - await client.query(`DELETE FROM report_query WHERE report_id = $1`, [ - reportId, - ]); + const companyCondition = companyCode !== "*" ? " AND company_code = $2" : ""; + const params = companyCode !== "*" ? [reportId, companyCode] : [reportId]; - // 레이아웃 삭제 - await client.query(`DELETE FROM report_layout WHERE report_id = $1`, [ - reportId, - ]); + const existing = await client.query( + `SELECT report_id FROM report_master WHERE report_id = $1${companyCondition}`, + params + ); + if (existing.rows.length === 0) return false; + + await client.query(`DELETE FROM report_menu_mapping WHERE report_id = $1`, [reportId]); + 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] + `DELETE FROM report_master WHERE report_id = $1${companyCondition}`, + params ); return (result.rowCount ?? 0) > 0; }); } - /** - * 리포트 복사 - */ - async copyReport(reportId: string, userId: string): Promise { + async copyReport( + reportId: string, + userId: string, + companyCode: string, + newName?: string + ): Promise { return transaction(async (client) => { - // 원본 리포트 조회 - const originalQuery = ` - SELECT * FROM report_master WHERE report_id = $1 - `; - const originalResult = await client.query(originalQuery, [reportId]); + const companyCondition = companyCode !== "*" ? " AND company_code = $2" : ""; + const params = companyCode !== "*" ? [reportId, companyCode] : [reportId]; - if (originalResult.rows.length === 0) { - return null; - } + const originalResult = await client.query( + `SELECT * FROM report_master WHERE report_id = $1${companyCondition}`, + params + ); + + if (originalResult.rows.length === 0) return null; const original = originalResult.rows[0]; - const newReportId = `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + const newReportId = generateReportId(); - // 리포트 마스터 복사 - 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) - `; - - // components가 이미 문자열이면 그대로, 객체면 JSON.stringify - const componentsData = - typeof originalLayout.components === "string" - ? originalLayout.components - : JSON.stringify(originalLayout.components); - - await client.query(copyLayoutQuery, [ - newLayoutId, + await client.query( + `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)`, + [ newReportId, - originalLayout.canvas_width, - originalLayout.canvas_height, - originalLayout.page_orientation, - originalLayout.margin_top, - originalLayout.margin_bottom, - originalLayout.margin_left, - originalLayout.margin_right, - componentsData, + newName || `${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 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, - ]); - } - } + await this.copyLayoutData(client, reportId, newReportId, userId); + await this.copyQueryData(client, reportId, newReportId, 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 - `; + private async copyLayoutData( + client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, + sourceReportId: string, + targetReportId: string, + userId: string + ): Promise { + const layoutResult = await client.query( + `SELECT * FROM report_layout WHERE report_id = $1`, + [sourceReportId] + ); - return queryOne(layoutQuery, [reportId]); + if (layoutResult.rows.length === 0) return; + + const originalLayout = layoutResult.rows[0]; + const componentsData = typeof originalLayout.components === "string" + ? originalLayout.components + : JSON.stringify(originalLayout.components); + + await client.query( + `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)`, + [ + generateLayoutId(), targetReportId, + originalLayout.canvas_width, originalLayout.canvas_height, + originalLayout.page_orientation, + originalLayout.margin_top, originalLayout.margin_bottom, + originalLayout.margin_left, originalLayout.margin_right, + componentsData, userId, + ] + ); + } + + private async copyQueryData( + client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, + sourceReportId: string, + targetReportId: string, + userId: string + ): Promise { + const queriesResult = await client.query( + `SELECT * FROM report_query WHERE report_id = $1 ORDER BY display_order`, + [sourceReportId] + ); + + for (const originalQuery of queriesResult.rows) { + await client.query( + `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)`, + [ + generateQueryId(), targetReportId, + originalQuery.query_name, originalQuery.query_type, + originalQuery.sql_query, JSON.stringify(originalQuery.parameters), + originalQuery.external_connection_id || null, + originalQuery.display_order, userId, + ] + ); + } + } + + async getLayout(reportId: string, companyCode?: string): Promise { + if (companyCode && companyCode !== "*") { + const ownerCheck = await queryOne<{ report_id: string }>( + `SELECT report_id FROM report_master WHERE report_id = $1 AND company_code = $2`, + [reportId, companyCode] + ); + if (!ownerCheck) return null; + } + return this.getLayoutInternal(reportId); + } + + private async getLayoutInternal(reportId: string): Promise { + const layoutRaw = await queryOne( + `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`, + [reportId] + ); + if (!layoutRaw) return null; + + return { + ...layoutRaw, + components: parseJsonComponents(layoutRaw.components as string | Record | null) as unknown as string, + }; } - /** - * 레이아웃 저장 (쿼리 포함) - 페이지 기반 구조 - */ async saveLayout( reportId: string, data: SaveLayoutRequest, - userId: string + userId: string, + companyCode: string ): Promise { return transaction(async (client) => { - // 첫 번째 페이지 정보를 기본 레이아웃으로 사용 - const firstPage = data.layoutConfig.pages[0]; - const canvasWidth = firstPage?.width || 210; - const canvasHeight = firstPage?.height || 297; - const pageOrientation = - canvasWidth > canvasHeight ? "landscape" : "portrait"; - const margins = firstPage?.margins || { - top: 20, - bottom: 20, - left: 20, - right: 20, - }; - - // 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) { - // 업데이트 - components 컬럼에 전체 layoutConfig 저장 - 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, [ - canvasWidth, - canvasHeight, - pageOrientation, - margins.top, - margins.bottom, - margins.left, - margins.right, - JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장 - 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, - canvasWidth, - canvasHeight, - pageOrientation, - margins.top, - margins.bottom, - margins.left, - margins.right, - JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장 - 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, - ]); - } - } - - // 3. 메뉴 매핑 저장 (있는 경우) - if (data.menuObjids !== undefined) { - // 기존 메뉴 매핑 모두 삭제 - await client.query( - `DELETE FROM report_menu_mapping WHERE report_id = $1`, - [reportId] + if (companyCode !== "*") { + const ownerCheck = await client.query( + `SELECT report_id FROM report_master WHERE report_id = $1 AND company_code = $2`, + [reportId, companyCode] ); - - // 새 메뉴 매핑 삽입 - if (data.menuObjids.length > 0) { - // 리포트의 company_code 조회 - const reportResult = await client.query( - `SELECT company_code FROM report_master WHERE report_id = $1`, - [reportId] - ); - const companyCode = reportResult.rows[0]?.company_code || "*"; - - const insertMappingSql = ` - INSERT INTO report_menu_mapping ( - report_id, - menu_objid, - company_code, - created_by - ) VALUES ($1, $2, $3, $4) - `; - - for (const menuObjid of data.menuObjids) { - await client.query(insertMappingSql, [ - reportId, - menuObjid, - companyCode, - userId, - ]); - } - } + if (ownerCheck.rows.length === 0) return false; } + const firstPage = data.layoutConfig.pages[0]; + const canvasWidth = firstPage?.width || DEFAULT_CANVAS_WIDTH; + const canvasHeight = firstPage?.height || DEFAULT_CANVAS_HEIGHT; + const pageOrientation = canvasWidth > canvasHeight ? "landscape" : "portrait"; + const margins = firstPage?.margins || DEFAULT_MARGINS; + + await this.upsertLayout(client, reportId, { + canvasWidth, canvasHeight, pageOrientation, margins, + componentsJson: JSON.stringify(data.layoutConfig), + userId, + }); + + if (data.queries && data.queries.length > 0) { + await this.replaceQueries(client, reportId, data.queries, userId); + } + + if (data.menuObjids !== undefined) { + await this.replaceMenuMappings(client, reportId, data.menuObjids, companyCode, userId); + } + + await client.query( + `UPDATE report_master SET updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE report_id = $2`, + [userId, reportId] + ); + return true; }); } - /** - * 쿼리 실행 (내부 DB 또는 외부 DB) - */ + private async upsertLayout( + client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, + reportId: string, + opts: { + canvasWidth: number; canvasHeight: number; pageOrientation: string; + margins: { top: number; bottom: number; left: number; right: number }; + componentsJson: string; userId: string; + } + ): Promise { + const existing = await client.query( + `SELECT layout_id FROM report_layout WHERE report_id = $1`, + [reportId] + ); + + const layoutParams = [ + opts.canvasWidth, opts.canvasHeight, opts.pageOrientation, + opts.margins.top, opts.margins.bottom, opts.margins.left, opts.margins.right, + opts.componentsJson, opts.userId, + ]; + + if (existing.rows.length > 0) { + await client.query( + `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`, + [...layoutParams, reportId] + ); + } else { + await client.query( + `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)`, + [generateLayoutId(), reportId, ...layoutParams] + ); + } + } + + private async replaceQueries( + client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, + reportId: string, + queries: NonNullable, + userId: string + ): Promise { + await client.query(`DELETE FROM report_query WHERE report_id = $1`, [reportId]); + + for (let i = 0; i < queries.length; i++) { + const q = queries[i]; + await client.query( + `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)`, + [ + q.id, reportId, q.name, q.type, q.sqlQuery, + JSON.stringify(q.parameters), + q.externalConnectionId || null, + i, userId, + ] + ); + } + } + + private async replaceMenuMappings( + client: { query: (sql: string, params: unknown[]) => Promise<{ rows: Record[] }> }, + reportId: string, + menuObjids: number[], + companyCode: string, + userId: string + ): Promise { + await client.query(`DELETE FROM report_menu_mapping WHERE report_id = $1`, [reportId]); + + if (menuObjids.length === 0) return; + + const reportResult = await client.query( + `SELECT company_code FROM report_master WHERE report_id = $1`, + [reportId] + ); + const resolvedCompanyCode = (reportResult.rows[0]?.company_code as string) || companyCode; + + for (const menuObjid of menuObjids) { + await client.query( + `INSERT INTO report_menu_mapping (report_id, menu_objid, company_code, created_by) + VALUES ($1, $2, $3, $4)`, + [reportId, menuObjid, resolvedCompanyCode, userId] + ); + } + } + async executeQuery( reportId: string, queryId: string, - parameters: Record, + parameters: Record, sqlQuery?: string, externalConnectionId?: number | null - ): Promise<{ fields: string[]; rows: any[] }> { - let sql_query: string; + ): Promise<{ fields: string[]; rows: Record[] }> { + let sqlToExecute: 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; - } + sqlToExecute = sqlQuery; + queryParameters = this.extractUniqueParams(sqlQuery); } else { - // DB에서 쿼리 조회 - const queryResult = await queryOne( + const storedQuery = await queryOne( `SELECT * FROM report_query WHERE query_id = $1 AND report_id = $2`, [queryId, reportId] ); - if (!queryResult) { + if (!storedQuery) { throw new Error("쿼리를 찾을 수 없습니다."); } - sql_query = queryResult.sql_query; - queryParameters = Array.isArray(queryResult.parameters) - ? queryResult.parameters - : []; - connectionId = queryResult.external_connection_id; + sqlToExecute = storedQuery.sql_query; + queryParameters = Array.isArray(storedQuery.parameters) ? storedQuery.parameters : []; + connectionId = storedQuery.external_connection_id; } - // SQL 쿼리 안전성 검증 (SELECT만 허용) - this.validateQuerySafety(sql_query); + this.validateQuerySafety(sqlToExecute); - // 파라미터 배열 생성 ($1, $2 순서대로) - const paramArray: any[] = []; - for (const param of queryParameters) { - paramArray.push(parameters[param] || null); - } + const paramArray = queryParameters.map((param) => parameters[param] ?? null); + const { sql: finalSql, params: finalParams } = + this.buildPreviewSqlIfNeeded(sqlToExecute, queryParameters, paramArray); try { - let result: any[]; + const result = connectionId + ? await this.executeOnExternalDb(finalSql, connectionId) + : await query(finalSql, finalParams); - // 외부 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}`); + return { fields, rows: result as Record[] }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "알 수 없는 오류"; + throw new Error(`쿼리 실행 오류: ${message}`); } } - /** - * 템플릿 목록 조회 - */ + private extractUniqueParams(sql: string): string[] { + const matches = sql.match(/\$\d+/g); + if (!matches) return []; + return [...new Set(matches)]; + } + + private buildPreviewSqlIfNeeded( + sql: string, + queryParameters: string[], + paramArray: (string | number | null)[] + ): { sql: string; params: (string | number | null)[] } { + const allParamsNull = paramArray.length > 0 && paramArray.every((p) => p === null); + if (!allParamsNull) return { sql, params: paramArray }; + + let previewSql = sql; + for (const param of queryParameters) { + const escapedParam = param.replace("$", "\\$"); + const conditionPattern = new RegExp( + `\\S+\\s*(?:=|!=|<>|>=|<=|>|<|LIKE|ILIKE|IN\\s*\\()\\s*${escapedParam}\\)?`, + "gi" + ); + previewSql = previewSql.replace(conditionPattern, "TRUE"); + } + + if (!/\bLIMIT\b/i.test(previewSql)) { + previewSql = previewSql.replace(/;?\s*$/, " LIMIT 100"); + } + + return { sql: previewSql, params: [] }; + } + + private async executeOnExternalDb( + sql: string, + connectionId: number + ): Promise[]> { + const connectionResult = await ExternalDbConnectionService.getConnectionById(connectionId); + + if (!connectionResult.success || !connectionResult.data) { + throw new Error("외부 DB 연결 정보를 찾을 수 없습니다."); + } + + const connection = connectionResult.data; + const connector = await DatabaseConnectorFactory.createConnector( + connection.db_type, + { + host: connection.host, + port: connection.port, + database: connection.database_name, + user: connection.username, + password: connection.password, + connectionTimeoutMillis: connection.connection_timeout || 30000, + queryTimeoutMillis: connection.query_timeout || 30000, + }, + connectionId + ); + + await connector.connect(); + try { + const queryResult = await connector.executeQuery(sql); + return (queryResult.rows || []) as Record[]; + } finally { + await connector.disconnect(); + } + } + + async getReportsByMenuObjid( + menuObjid: number, + companyCode: string + ): Promise<{ items: ReportMaster[]; total: number }> { + const companyFilter = companyCode !== "*" ? " AND rm.company_code = $2" : ""; + const params = companyCode !== "*" ? [menuObjid, companyCode] : [menuObjid]; + + const items = await query( + `SELECT rm.report_id, rm.report_name_kor, rm.report_name_eng, + rm.template_id, rt.template_name_kor AS template_name, + rm.report_type, rm.company_code, rm.description, rm.use_yn, + rm.created_at, rm.created_by, rm.updated_at, rm.updated_by + FROM report_master rm + JOIN report_menu_mapping rmm ON rm.report_id = rmm.report_id + LEFT JOIN report_template rt ON rm.template_id = rt.template_id + WHERE rmm.menu_objid = $1 AND rm.use_yn = 'Y'${companyFilter} + ORDER BY rm.report_name_kor ASC`, + params + ); + + return { items: items || [], total: (items || []).length }; + } + async getTemplates(): Promise { const templateQuery = ` - SELECT + SELECT template_id, template_name_kor, template_name_eng, @@ -898,14 +934,11 @@ export class ReportService { return { system, custom }; } - /** - * 템플릿 생성 (사용자 정의) - */ async createTemplate( data: CreateTemplateRequest, userId: string ): Promise { - const templateId = `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + const templateId = generateTemplateId(); const insertQuery = ` INSERT INTO report_template ( @@ -936,22 +969,16 @@ export class ReportService { 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]); + await query(deleteQuery, [templateId]); return true; } - /** - * 현재 리포트를 템플릿으로 저장 - */ async saveAsTemplate( reportId: string, templateNameKor: string, @@ -960,7 +987,6 @@ export class ReportService { userId: string ): Promise { return transaction(async (client) => { - // 리포트 정보 조회 const reportQuery = ` SELECT report_type FROM report_master WHERE report_id = $1 `; @@ -972,7 +998,6 @@ export class ReportService { const reportType = reportResult.rows[0].report_type; - // 레이아웃 조회 const layoutQuery = ` SELECT canvas_width, @@ -994,7 +1019,6 @@ export class ReportService { const layout = layoutResult.rows[0]; - // 쿼리 조회 const queriesQuery = ` SELECT query_name, @@ -1009,7 +1033,6 @@ export class ReportService { `; const queriesResult = await client.query(queriesQuery, [reportId]); - // 레이아웃 설정 JSON 생성 const layoutConfig = { width: layout.canvas_width, height: layout.canvas_height, @@ -1020,10 +1043,9 @@ export class ReportService { left: layout.margin_left, right: layout.margin_right, }, - components: JSON.parse(layout.components || "[]"), + components: typeof layout.components === "string" ? JSON.parse(layout.components || "[]") : (layout.components || []), }; - // 기본 쿼리 JSON 생성 const defaultQueries = queriesResult.rows.map((q) => ({ name: q.query_name, type: q.query_type, @@ -1033,41 +1055,24 @@ export class ReportService { displayOrder: q.display_order, })); - // 템플릿 생성 - const templateId = `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + const templateId = generateTemplateId(); - 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, - ]); + await client.query( + `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)`, + [ + templateId, templateNameKor, templateNameEng || null, reportType, + description || null, JSON.stringify(layoutConfig), + JSON.stringify(defaultQueries), userId, + ] + ); return templateId; }); } - // 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요) async createTemplateFromLayout( templateNameKor: string, templateNameEng: string | null | undefined, @@ -1077,13 +1082,8 @@ export class ReportService { width: number; height: number; orientation: string; - margins: { - top: number; - bottom: number; - left: number; - right: number; - }; - components: any[]; + margins: { top: number; bottom: number; left: number; right: number }; + components: Record[]; }, defaultQueries: Array<{ name: string; @@ -1095,38 +1095,127 @@ export class ReportService { }>, userId: string ): Promise { - const templateId = `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; + const templateId = generateTemplateId(); - 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 + await query( + `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, - ]); + RETURNING template_id`, + [ + templateId, templateNameKor, templateNameEng || null, templateType, + description || null, JSON.stringify(layoutConfig), + JSON.stringify(defaultQueries), userId, + ] + ); return templateId; } + + // ─── 비주얼 쿼리 빌더 ───────────────────────────────────────────────────────── + + /** information_schema에서 사용자 테이블 목록 조회 */ + async getSchemaTables(): Promise> { + const sql = ` + SELECT table_name, table_type + FROM information_schema.tables + WHERE table_schema = 'public' + AND table_type IN ('BASE TABLE', 'VIEW') + ORDER BY table_name + `; + return query<{ table_name: string; table_type: string }>(sql, []); + } + + /** 특정 테이블의 컬럼 정보 조회 */ + async getSchemaTableColumns( + tableName: string + ): Promise> { + this.validateTableName(tableName); + const sql = ` + SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = $1 + ORDER BY ordinal_position + `; + return query<{ column_name: string; data_type: string; is_nullable: string }>(sql, [tableName]); + } + + /** VisualQuery → SELECT SQL 문자열 빌드 (순수 함수) */ + buildVisualQuerySql(vq: VisualQuery): string { + this.validateTableName(vq.tableName); + + const selectParts: string[] = []; + + for (const col of vq.columns) { + this.validateIdentifier(col); + selectParts.push(`"${col}"`); + } + + for (const fc of vq.formulaColumns) { + this.validateFormulaExpression(fc.expression); + this.validateIdentifier(fc.alias); + selectParts.push(`(${fc.expression}) AS "${fc.alias}"`); + } + + if (selectParts.length === 0) { + throw new Error("최소 1개 이상의 컬럼을 선택해야 합니다."); + } + + const limit = Math.min(Math.max(vq.limit ?? 100, 1), 10000); + return `SELECT ${selectParts.join(", ")} FROM "${vq.tableName}" LIMIT ${limit}`; + } + + async executeVisualQuery(vq: VisualQuery): Promise<{ fields: string[]; rows: Record[] }> { + const sql = this.buildVisualQuerySql(vq); + this.validateQuerySafety(sql); + + const result = await query(sql, []); + const fields = result.length > 0 ? Object.keys(result[0]) : []; + return { fields, rows: result as Record[] }; + } + + // ─── 비주얼 쿼리 검증 헬퍼 ───────────────────────────────────────────────────── + + /** 테이블/컬럼명 화이트리스트 검증 — 영문+숫자+밑줄만 허용 */ + private validateTableName(name: string): void { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + throw new Error(`유효하지 않은 테이블명입니다: ${name}`); + } + } + + private validateIdentifier(name: string): void { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + throw new Error(`유효하지 않은 식별자입니다: ${name}`); + } + } + + /** 수식 표현식 안전성 검증 — 세미콜론, 주석, 서브쿼리 금지 */ + private validateFormulaExpression(expr: string): void { + const forbidden = [";", "--", "/*", "*/", "SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE"]; + const upper = expr.toUpperCase(); + for (const keyword of forbidden) { + if (upper.includes(keyword)) { + throw new Error(`수식에 사용할 수 없는 키워드가 포함되어 있습니다: ${keyword}`); + } + } + } + + // ─── 카테고리(report_type) 관리 ───────────────────────────────────────────────── + + /** DB에 저장된 모든 카테고리(report_type) 목록 조회 (중복 제거, 정렬) */ + async getCategories(): Promise { + const sql = ` + SELECT DISTINCT report_type + FROM report_master + WHERE report_type IS NOT NULL + AND report_type != '' + ORDER BY report_type ASC + `; + const rows = await query<{ report_type: string }>(sql, []); + return rows.map((r) => r.report_type); + } } export default new ReportService(); diff --git a/backend-node/src/types/report.ts b/backend-node/src/types/report.ts index fc79df32..266f2aa9 100644 --- a/backend-node/src/types/report.ts +++ b/backend-node/src/types/report.ts @@ -1,8 +1,3 @@ -/** - * 리포트 관리 시스템 타입 정의 - */ - -// 리포트 템플릿 export interface ReportTemplate { template_id: string; template_name_kor: string; @@ -21,12 +16,12 @@ export interface ReportTemplate { updated_by: string | null; } -// 리포트 마스터 export interface ReportMaster { report_id: string; report_name_kor: string; report_name_eng: string | null; template_id: string | null; + template_name: string | null; report_type: string; company_code: string | null; description: string | null; @@ -37,7 +32,6 @@ export interface ReportMaster { updated_by: string | null; } -// 리포트 레이아웃 export interface ReportLayout { layout_id: string; report_id: string; @@ -55,7 +49,6 @@ export interface ReportLayout { updated_by: string | null; } -// 리포트 쿼리 export interface ReportQuery { query_id: string; report_id: string; @@ -63,7 +56,7 @@ export interface ReportQuery { query_type: "MASTER" | "DETAIL"; sql_query: string; parameters: string[] | null; - external_connection_id: number | null; // 외부 DB 연결 ID (NULL이면 내부 DB) + external_connection_id: number | null; display_order: number; created_at: Date; created_by: string | null; @@ -71,34 +64,37 @@ export interface ReportQuery { updated_by: string | null; } -// 리포트 상세 (마스터 + 레이아웃 + 쿼리 + 연결된 메뉴) export interface ReportDetail { report: ReportMaster; layout: ReportLayout | null; queries: ReportQuery[]; - menuObjids?: number[]; // 연결된 메뉴 ID 목록 + menuObjids?: number[]; } -// 리포트 목록 조회 파라미터 export interface GetReportsParams { page?: number; limit?: number; searchText?: string; + searchField?: "report_name" | "created_by" | "report_type" | "updated_at" | "created_at"; + startDate?: string; + endDate?: string; reportType?: string; useYn?: string; sortBy?: string; sortOrder?: "ASC" | "DESC"; } -// 리포트 목록 응답 export interface GetReportsResponse { items: ReportMaster[]; total: number; page: number; limit: number; + typeSummary: Array<{ type: string; count: number }>; + allTypes: string[]; + recentActivity: Array<{ date: string; count: number }>; + recentTotal: number; } -// 리포트 생성 요청 export interface CreateReportRequest { reportNameKor: string; reportNameEng?: string; @@ -108,7 +104,6 @@ export interface CreateReportRequest { companyCode?: string; } -// 리포트 수정 요청 export interface UpdateReportRequest { reportNameKor?: string; reportNameEng?: string; @@ -117,23 +112,18 @@ export interface UpdateReportRequest { useYn?: string; } -// 워터마크 설정 export interface WatermarkConfig { enabled: boolean; type: "text" | "image"; - // 텍스트 워터마크 text?: string; fontSize?: number; fontColor?: string; - // 이미지 워터마크 imageUrl?: string; - // 공통 설정 - opacity: number; // 0~1 + opacity: number; style: "diagonal" | "center" | "tile"; - rotation?: number; // 대각선일 때 각도 (기본 -45) + rotation?: number; } -// 페이지 설정 export interface PageConfig { page_id: string; page_name: string; @@ -147,30 +137,29 @@ export interface PageConfig { left: number; right: number; }; - components: any[]; + components: Record[]; } -// 레이아웃 설정 export interface ReportLayoutConfig { pages: PageConfig[]; - watermark?: WatermarkConfig; // 전체 페이지 공유 워터마크 + watermark?: WatermarkConfig; +} + +export interface SaveLayoutQueryItem { + id: string; + name: string; + type: "MASTER" | "DETAIL"; + sqlQuery: string; + parameters: string[]; + externalConnectionId?: number | null; } -// 레이아웃 저장 요청 export interface SaveLayoutRequest { layoutConfig: ReportLayoutConfig; - queries?: Array<{ - id: string; - name: string; - type: "MASTER" | "DETAIL"; - sqlQuery: string; - parameters: string[]; - externalConnectionId?: number; - }>; - menuObjids?: number[]; // 연결할 메뉴 ID 목록 + queries?: SaveLayoutQueryItem[]; + menuObjids?: number[]; } -// 리포트-메뉴 매핑 export interface ReportMenuMapping { mapping_id: number; report_id: string; @@ -180,23 +169,20 @@ export interface ReportMenuMapping { created_by: string | null; } -// 템플릿 목록 응답 export interface GetTemplatesResponse { system: ReportTemplate[]; custom: ReportTemplate[]; } -// 템플릿 생성 요청 export interface CreateTemplateRequest { templateNameKor: string; templateNameEng?: string; templateType: string; description?: string; - layoutConfig?: any; - defaultQueries?: any; + layoutConfig?: Record; + defaultQueries?: Array>; } -// 컴포넌트 설정 (프론트엔드와 동기화) export interface ComponentConfig { id: string; type: string; @@ -224,21 +210,16 @@ export interface ComponentConfig { conditional?: string; locked?: boolean; groupId?: string; - // 이미지 전용 imageUrl?: string; objectFit?: "contain" | "cover" | "fill" | "none"; - // 구분선 전용 orientation?: "horizontal" | "vertical"; lineStyle?: "solid" | "dashed" | "dotted" | "double"; lineWidth?: number; lineColor?: string; - // 서명/도장 전용 showLabel?: boolean; labelText?: string; labelPosition?: "top" | "left" | "bottom" | "right"; - showUnderline?: boolean; personName?: string; - // 테이블 전용 tableColumns?: Array<{ field: string; header: string; @@ -249,9 +230,7 @@ export interface ComponentConfig { headerTextColor?: string; showBorder?: boolean; rowHeight?: number; - // 페이지 번호 전용 pageNumberFormat?: "number" | "numberTotal" | "koreanNumber"; - // 카드 컴포넌트 전용 cardTitle?: string; cardItems?: Array<{ label: string; @@ -267,7 +246,6 @@ export interface ComponentConfig { titleColor?: string; labelColor?: string; valueColor?: string; - // 계산 컴포넌트 전용 calcItems?: Array<{ label: string; value: number | string; @@ -280,7 +258,6 @@ export interface ComponentConfig { showCalcBorder?: boolean; numberFormat?: "none" | "comma" | "currency"; currencySuffix?: string; - // 바코드 컴포넌트 전용 barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR"; barcodeValue?: string; barcodeFieldName?: string; @@ -289,19 +266,118 @@ export interface ComponentConfig { barcodeBackground?: string; barcodeMargin?: number; qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H"; - // QR코드 다중 필드 (JSON 형식) qrDataFields?: Array<{ fieldName: string; label: string; }>; qrUseMultiField?: boolean; qrIncludeAllRows?: boolean; - // 체크박스 컴포넌트 전용 - checkboxChecked?: boolean; // 체크 상태 (고정값) - checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값) - checkboxLabel?: string; // 체크박스 옆 레이블 텍스트 - checkboxSize?: number; // 체크박스 크기 (px) - checkboxColor?: string; // 체크 색상 - checkboxBorderColor?: string; // 테두리 색상 - checkboxLabelPosition?: "left" | "right"; // 레이블 위치 + checkboxChecked?: boolean; + checkboxFieldName?: string; + checkboxLabel?: string; + checkboxSize?: number; + checkboxColor?: string; + checkboxBorderColor?: string; + checkboxLabelPosition?: "left" | "right"; + visualQuery?: VisualQuery; + // 카드 레이아웃 설정 (card 컴포넌트 전용 - v3) + cardLayoutConfig?: CardLayoutConfig; +} + +export interface VisualQueryFormulaColumn { + alias: string; + header: string; + expression: string; +} + +export interface VisualQuery { + tableName: string; + limit?: number; + columns: string[]; + formulaColumns: VisualQueryFormulaColumn[]; +} + +// ───────────────────────────────────────────────────────────────────────────── +// 카드 레이아웃 v3 타입 정의 +// ───────────────────────────────────────────────────────────────────────────── + +export type CardElementType = "header" | "dataCell" | "divider" | "badge"; +export type CellDirection = "vertical" | "horizontal"; + +export interface CardElementBase { + id: string; + type: CardElementType; + colspan?: number; + rowspan?: number; +} + +export interface CardHeaderElement extends CardElementBase { + type: "header"; + icon?: string; + iconColor?: string; + title: string; + titleColor?: string; + titleFontSize?: number; +} + +export interface CardDataCellElement extends CardElementBase { + type: "dataCell"; + direction: CellDirection; + label: string; + columnName?: string; + inputType?: "text" | "date" | "number" | "select" | "readonly"; + required?: boolean; + placeholder?: string; + selectOptions?: string[]; + labelWidth?: number; + labelFontSize?: number; + labelColor?: string; + valueFontSize?: number; + valueColor?: string; +} + +export interface CardDividerElement extends CardElementBase { + type: "divider"; + style?: "solid" | "dashed" | "dotted"; + color?: string; + thickness?: number; +} + +export interface CardBadgeElement extends CardElementBase { + type: "badge"; + label?: string; + columnName?: string; + colorMap?: Record; +} + +export type CardElement = + | CardHeaderElement + | CardDataCellElement + | CardDividerElement + | CardBadgeElement; + +export interface CardLayoutRow { + id: string; + gridColumns: number; + elements: CardElement[]; + height?: string; +} + +export interface CardLayoutConfig { + tableName?: string; + primaryKey?: string; + rows: CardLayoutRow[]; + padding?: string; + gap?: string; + borderStyle?: string; + borderColor?: string; + backgroundColor?: string; + headerTitleFontSize?: number; + headerTitleColor?: string; + labelFontSize?: number; + labelColor?: string; + valueFontSize?: number; + valueColor?: string; + dividerThickness?: number; + dividerColor?: string; } diff --git a/cursor-rules-backup-20260309.tar.gz b/cursor-rules-backup-20260309.tar.gz new file mode 100644 index 00000000..5f8eeb10 Binary files /dev/null and b/cursor-rules-backup-20260309.tar.gz differ diff --git a/db/migrations/RUN_MIGRATION_1004.md b/db/migrations/RUN_MIGRATION_1004.md new file mode 100644 index 00000000..fb1a0d11 --- /dev/null +++ b/db/migrations/RUN_MIGRATION_1004.md @@ -0,0 +1,17 @@ +# Migration 1004: report_query에 visual_data_source 컬럼 추가 + +## 목적 +비주얼 데이터 소스 빌더의 UI 설정 원본을 저장하여, 다시 편집할 때 복원할 수 있도록 한다. + +## 실행 SQL + +```sql +ALTER TABLE report_query ADD COLUMN IF NOT EXISTS visual_data_source JSONB DEFAULT NULL; +COMMENT ON COLUMN report_query.visual_data_source IS '비주얼 데이터 소스 빌더 UI 설정 원본 (다시 편집할 때 복원용)'; +``` + +## 롤백 SQL + +```sql +ALTER TABLE report_query DROP COLUMN IF EXISTS visual_data_source; +``` diff --git a/frontend/app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx index 1375eeb3..ec5abe07 100644 --- a/frontend/app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx +++ b/frontend/app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx @@ -1,29 +1,87 @@ "use client"; -import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState, useCallback } from "react"; +import { useParams } from "next/navigation"; import { DndProvider } from "react-dnd"; +import { useTabStore } from "@/stores/tabStore"; +import { useTabId } from "@/contexts/TabIdContext"; 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 { ReportDesignerProvider, useReportDesigner } from "@/contexts/ReportDesignerContext"; +import { ComponentSettingsModal } from "@/components/report/designer/modals/ComponentSettingsModal"; 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 BREAKPOINT_COLLAPSE_LEFT = 1200; +const BREAKPOINT_COLLAPSE_ALL = 900; + +function DesignerLayout() { + const { + setIsPageListCollapsed, + setIsLeftPanelCollapsed, + setIsRightPanelCollapsed, + } = useReportDesigner(); + + const handleResize = useCallback(() => { + const w = window.innerWidth; + if (w < BREAKPOINT_COLLAPSE_ALL) { + setIsPageListCollapsed(true); + setIsLeftPanelCollapsed(true); + setIsRightPanelCollapsed(true); + } else if (w < BREAKPOINT_COLLAPSE_LEFT) { + setIsPageListCollapsed(true); + setIsLeftPanelCollapsed(false); + setIsRightPanelCollapsed(false); + } + }, [setIsPageListCollapsed, setIsLeftPanelCollapsed, setIsRightPanelCollapsed]); + + useEffect(() => { + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [handleResize]); + + return ( +
+ + +
+ + + + +
+ + +
+ ); +} + +interface ReportDesignerPageProps { + adminParams?: { reportId?: string }; +} + +export default function ReportDesignerPage({ adminParams }: ReportDesignerPageProps) { + const routeParams = useParams(); + const reportId = adminParams?.reportId || (routeParams.reportId as string); const [isLoading, setIsLoading] = useState(true); const { toast } = useToast(); + const closeTab = useTabStore((s) => s.closeTab); + const currentTabId = useTabId(); + + const closeDesignerTab = useCallback(() => { + if (currentTabId) { + closeTab(currentTabId); + } + }, [currentTabId, closeTab]); useEffect(() => { const loadReport = async () => { - // 'new'는 새 리포트 생성 모드 if (reportId === "new") { setIsLoading(false); return; @@ -37,7 +95,7 @@ export default function ReportDesignerPage() { description: "리포트를 찾을 수 없습니다.", variant: "destructive", }); - router.push("/admin/screenMng/reportList"); + closeDesignerTab(); } } catch (error: any) { toast({ @@ -45,7 +103,7 @@ export default function ReportDesignerPage() { description: error.message || "리포트를 불러오는데 실패했습니다.", variant: "destructive", }); - router.push("/admin/screenMng/reportList"); + closeDesignerTab(); } finally { setIsLoading(false); } @@ -54,7 +112,7 @@ export default function ReportDesignerPage() { if (reportId) { loadReport(); } - }, [reportId, router, toast]); + }, [reportId, closeDesignerTab, toast]); if (isLoading) { return ( @@ -65,28 +123,12 @@ export default function ReportDesignerPage() { } return ( - - -
- {/* 상단 툴바 */} - - - {/* 메인 영역 */} -
- {/* 페이지 목록 패널 */} - - - {/* 좌측 패널 (템플릿, 컴포넌트) */} - - - {/* 중앙 캔버스 */} - - - {/* 우측 패널 (속성) */} - -
-
-
-
+
+ + + + + +
); } diff --git a/frontend/app/(main)/admin/screenMng/reportList/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/page.tsx index e3f1e890..31b5cf4c 100644 --- a/frontend/app/(main)/admin/screenMng/reportList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/reportList/page.tsx @@ -1,104 +1,528 @@ "use client"; -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useState, useMemo, useRef, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Calendar } from "@/components/ui/calendar"; import { ReportListTable } from "@/components/report/ReportListTable"; -import { Plus, Search, RotateCcw } from "lucide-react"; +import { ReportCreateModal } from "@/components/report/ReportCreateModal"; +import { ReportCopyModal } from "@/components/report/ReportCopyModal"; +import { ReportListPreviewModal } from "@/components/report/ReportListPreviewModal"; +import { + Plus, + Search, + LayoutGrid, + List, + FileText, + Users, + SlidersHorizontal, + Check, + Tag, + CalendarDays, + User, + X, +} from "lucide-react"; import { useReportList } from "@/hooks/useReportList"; +import { ReportMaster } from "@/types/report"; +import { PieChart, Pie, Cell, Tooltip, BarChart, Bar, XAxis, LabelList } from "recharts"; +import { REPORT_TYPE_COLORS, getTypeColorIndex, getTypeLabel, getTypeIcon } from "@/lib/reportTypeColors"; +import { format, subDays, subMonths, startOfDay } from "date-fns"; +import { ko } from "date-fns/locale"; + +const SEARCH_FIELD_OPTIONS = [ + { value: "report_type" as const, label: "카테고리", icon: Tag }, + { value: "report_name" as const, label: "리포트명", icon: FileText }, + { value: "updated_at" as const, label: "기간 검색", icon: CalendarDays }, + { value: "created_by" as const, label: "작성자", icon: User }, +]; export default function ReportManagementPage() { - const router = useRouter(); const [searchText, setSearchText] = useState(""); + const [viewMode, setViewMode] = useState<"grid" | "list">("list"); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [copyTarget, setCopyTarget] = useState(null); + const [viewTarget, setViewTarget] = useState(null); + const [filterOpen, setFilterOpen] = useState(false); + const [datePopoverOpen, setDatePopoverOpen] = useState(false); + const [tempStartDate, setTempStartDate] = useState(undefined); + const [tempEndDate, setTempEndDate] = useState(undefined); + const filterRef = useRef(null); - const { reports, total, page, limit, isLoading, refetch, setPage, handleSearch } = useReportList(); + const { + reports, + total, + typeSummary, + recentActivity, + recentTotal, + page, + limit, + isLoading, + searchField, + startDate, + endDate, + refetch, + setPage, + setLimit, + handleSearch, + handleSearchFieldChange, + handleDateRangeChange, + } = useReportList(); - const handleSearchClick = () => { - handleSearch(searchText); - }; + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (filterRef.current && !filterRef.current.contains(e.target as Node)) { + setFilterOpen(false); + setDatePopoverOpen(false); + } + }; + if (filterOpen) document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [filterOpen]); - const handleReset = () => { - setSearchText(""); - handleSearch(""); + const isDateFilterActive = searchField === "updated_at" && startDate && endDate; + + const handleDatePreset = useCallback((days: number) => { + const end = new Date(); + const start = days === 0 ? startOfDay(end) : subDays(end, days); + setTempStartDate(start); + setTempEndDate(end); + }, []); + + const handleMonthPreset = useCallback((months: number) => { + const end = new Date(); + const start = subMonths(end, months); + setTempStartDate(start); + setTempEndDate(end); + }, []); + + const handleApplyDateFilter = useCallback(() => { + if (!tempStartDate || !tempEndDate) return; + handleSearchFieldChange("updated_at"); + handleDateRangeChange(format(tempStartDate, "yyyy-MM-dd"), format(tempEndDate, "yyyy-MM-dd")); + setDatePopoverOpen(false); + setFilterOpen(false); + }, [tempStartDate, tempEndDate, handleSearchFieldChange, handleDateRangeChange]); + + const handleClearDateFilter = useCallback(() => { + setTempStartDate(undefined); + setTempEndDate(undefined); + handleSearchFieldChange("report_name"); + handleDateRangeChange("", ""); + }, [handleSearchFieldChange, handleDateRangeChange]); + + const typeData = useMemo(() => typeSummary.map(({ type, count }) => ({ type, value: count })), [typeSummary]); + + const authorStats = useMemo(() => { + const map = new Map(); + reports.forEach((r) => { + const author = r.created_by || "미지정"; + map.set(author, (map.get(author) || 0) + 1); + }); + return Array.from(map.entries()) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 3) + .sort((a, b) => a.name.localeCompare(b.name, "ko")); + }, [reports]); + + const authorCount = useMemo(() => new Set(reports.map((r) => r.created_by).filter(Boolean)).size, [reports]); + + const handleSearchClick = () => handleSearch(searchText); + + const handleViewModeChange = (mode: "grid" | "list") => { + setViewMode(mode); + setLimit(mode === "grid" ? 9 : 8); + setPage(1); }; const handleCreateNew = () => { - // 새 리포트는 'new'라는 특수 ID로 디자이너 진입 - router.push("/admin/screenMng/reportList/designer/new"); + setIsCreateOpen(true); }; - return ( -
-
- {/* 페이지 제목 */} -
-
-

리포트 관리

-

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

-
- -
+ const currentFieldLabel = SEARCH_FIELD_OPTIONS.find((o) => o.value === searchField)?.label ?? "리포트명"; - {/* 검색 영역 */} - - - - - 검색 - - - -
+ return ( +
+
+
+
+
+

리포트 관리

+ 리포트를 생성하고 관리합니다 +
+
+ +
+
+ setSearchText(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSearchClick(); - } - }} - className="flex-1" + onKeyDown={(e) => e.key === "Enter" && handleSearchClick()} + className="h-9 pl-9 text-sm" /> -
+ + + +
+ - + ); + })} +
+ )} + + {filterOpen && datePopoverOpen && ( +
e.stopPropagation()} + > +
+ + 기간 검색 +
+
+ +
+
+ {[ + { label: "오늘", action: () => handleDatePreset(0) }, + { label: "1주일", action: () => handleDatePreset(7) }, + { label: "1개월", action: () => handleMonthPreset(1) }, + { label: "3개월", action: () => handleMonthPreset(3) }, + ].map((preset) => ( + + ))} +
+ +
+
+ +
+ + {tempStartDate ? ( + format(tempStartDate, "yyyy-MM-dd") + ) : ( + 선택 + )} +
+ +
+
+ ~ +
+
+ +
+ + {tempEndDate ? ( + format(tempEndDate, "yyyy-MM-dd") + ) : ( + 선택 + )} +
+ +
+
+ + +
+
+ )} +
+ + {isDateFilterActive && ( +
+ + + {startDate} ~ {endDate} + + +
+ )} + +
+ +
- - - {/* 리포트 목록 */} - - - - - 📋 리포트 목록 - (총 {total}건) + +
+
+
+ +
+
+
+
+ +
+
+ 전체 리포트 +

+ {total.toLocaleString()} + +

+
+
+ +
+
+
+ +
+
+ 작성자 +

+ {authorCount.toLocaleString()} + +

+
+
+ {authorStats.length > 0 && ( +
+ + + + + [`${value}건`, "리포트"]} + labelFormatter={(_label: any, payload: any) => payload?.[0]?.payload?.name || _label} + contentStyle={{ fontSize: "11px", borderRadius: "6px", boxShadow: "0 2px 8px rgba(0,0,0,0.12)" }} + wrapperStyle={{ zIndex: 50, pointerEvents: "none" }} + cursor={false} + allowEscapeViewBox={{ x: true, y: true }} + /> + +
+ )} +
+ +
+ + 최근 30일 활동{" "} + + ({recentTotal.toLocaleString()}건) - - - + +
+
+ + + + [`${value}건`, "수정"]} + contentStyle={{ fontSize: "12px", borderRadius: "6px", boxShadow: "0 2px 8px rgba(0,0,0,0.12)" }} + cursor={false} + allowEscapeViewBox={{ x: true, y: true }} + /> + +
+
+
+ +
+ 카테고리별 분포 +
+ {typeData.length === 0 ? ( +

데이터 없음

+ ) : ( +
+
+ + + {typeData.map((entry) => ( + + ))} + + [`${value}건`, getTypeLabel(name)]} + contentStyle={{ fontSize: "12px", borderRadius: "6px", boxShadow: "0 2px 8px rgba(0,0,0,0.12)" }} + wrapperStyle={{ zIndex: 20, pointerEvents: "none" }} + allowEscapeViewBox={{ x: true, y: true }} + /> + +
+
+ {typeData.slice(0, 4).map((entry) => { + const TypeIcon = getTypeIcon(entry.type); + return ( +
+
+ +
+ {getTypeLabel(entry.type)} + {entry.value} +
+ ); + })} + {typeData.length > 4 && 외 {typeData.length - 4}개} +
+
+ )} +
+
+
+ +
+
+ + 리포트 목록 + (총 {total}건) + +
+
- - +
+
+ + setIsCreateOpen(false)} onSuccess={refetch} /> + + setViewTarget(null)} /> + + {copyTarget && ( + setCopyTarget(null)} + onSuccess={() => { + setCopyTarget(null); + refetch(); + }} + /> + )}
); } diff --git a/frontend/app/(main)/admin/screenMng/reportList/samples/components/DocumentLayout.tsx b/frontend/app/(main)/admin/screenMng/reportList/samples/components/DocumentLayout.tsx new file mode 100644 index 00000000..c2f76b6b --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/reportList/samples/components/DocumentLayout.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { ReactNode } from "react"; +import Link from "next/link"; +import { ArrowLeft, Printer, Download } from "lucide-react"; + +interface DocumentLayoutProps { + children: ReactNode; + title: string; + docNumber?: string; +} + +export default function DocumentLayout({ children, title, docNumber }: DocumentLayoutProps) { + return ( +
+ {/* Navigation Bar */} +
+
+
+ + + 돌아가기 + +
+

{title}

+ {docNumber && ( + {docNumber} + )} +
+
+ + +
+
+
+ + {/* Document Container */} +
+
+ {children} +
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/screenMng/reportList/samples/components/StatusBadge.tsx b/frontend/app/(main)/admin/screenMng/reportList/samples/components/StatusBadge.tsx new file mode 100644 index 00000000..f53c962f --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/reportList/samples/components/StatusBadge.tsx @@ -0,0 +1,31 @@ +type StatusType = "합격" | "불합격" | "보류" | "발주완료" | "검토중" | "취소" | "완료" | "승인대기"; + +interface StatusBadgeProps { + status: StatusType; + size?: "sm" | "md" | "lg"; +} + +const COLOR_MAP: Record = { + 합격: "bg-white text-[#16A34A] border-[#16A34A]", + 완료: "bg-white text-[#16A34A] border-[#16A34A]", + 발주완료: "bg-white text-[#16A34A] border-[#16A34A]", + 불합격: "bg-[#DC2626] text-white border-[#DC2626]", + 취소: "bg-[#DC2626] text-white border-[#DC2626]", + 보류: "bg-[#D97706] text-white border-[#D97706]", + 검토중: "bg-[#D97706] text-white border-[#D97706]", + 승인대기: "bg-[#2563EB] text-white border-[#2563EB]", +}; + +const SIZE_MAP = { + sm: "px-2 py-0.5 text-xs", + md: "px-3 py-1 text-sm", + lg: "px-8 py-3 text-2xl", +}; + +export default function StatusBadge({ status, size = "md" }: StatusBadgeProps) { + return ( + + {status} + + ); +} diff --git a/frontend/app/(main)/admin/screenMng/reportList/samples/inspection/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/samples/inspection/page.tsx new file mode 100644 index 00000000..0d905409 --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/reportList/samples/inspection/page.tsx @@ -0,0 +1,207 @@ +"use client"; + +import DocumentLayout from "../components/DocumentLayout"; +import StatusBadge from "../components/StatusBadge"; + +const INSPECTION_ITEMS = [ + { + no: 1, + item: "외관상태", + subItem: "ee", + method: "육안 및 뒤틀림이 없을 것", + standard: "A", + measured: ["A", "A", "A", "A", "A", "A", "A", "A"], + result: "합격" as const, + }, + { + no: 2, + item: "표면 및 표시", + subItem: "ff", + method: "100표에서 1시간 방치", + standard: "O", + measured: ["O", "O", "O", "O", "O", "O", "O", "O"], + result: "합격" as const, + }, + { + no: 3, + item: "치수 yy", + subItem: "yy", + method: "길이", + standard: "453.9±0.9", + measured: ["453.6", "453.6", "454.4", "453.5", "453.1", "454.1", "454.3", "454.7"], + result: "합격" as const, + }, + { + no: 4, + item: "치수 hhh", + subItem: "hhh", + method: "폭", + standard: "177.3±0.5", + measured: ["177.4", "177.1", "177.5", "177.6", "177.3", "176.9", "177.7", "176.8"], + result: "합격" as const, + }, + { + no: 5, + item: "외관상태", + subItem: "", + method: "ff", + standard: "A", + measured: ["A", "A", "A", "A", "A", "A", "A", "A"], + result: "합격" as const, + }, +]; + +// ── 정보 카드 (CardRenderer 구조를 참고한 정적 구현) ──────────────────────── + +function InfoCard({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
+

▣ {title}

+
+
{children}
+
+ ); +} + +function InfoRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +// ── 결재란 ──────────────────────────────────────────────────────────────────── + +function ApprovalSection({ columns }: { columns: string[] }) { + return ( +
+
+
+ {columns.map((col, i) => ( +
+
{col}
+
+
+ ))} +
+
+
+ ); +} + +export default function InspectionReportPage() { + return ( + +
+ {/* ── 헤더 ── */} +
+
+

검 사 보 고 서

+

INSPECTION REPORT

+
+
+
문서번호: IR-2026-00123
+ +
+
+ + {/* ── 기본 정보 (2열 카드) ── */} +
+ + + + + + + + + + + + + + + + + +
+ + {/* ── 검사 항목 테이블 ── */} +
+
+

▣ 검사/시험 측정값

+
+
+ + + + + + + + + + + {["X1", "X2", "X3", "X4", "X5", "X6", "X7", "X8"].map((x) => ( + + ))} + + + + {INSPECTION_ITEMS.map((item, idx) => ( + + + + {item.measured.map((val, i) => ( + + ))} + + + + ))} + +
검사항목 + 시험 및 검사대응
(검사기준) +
+ 검사/시험 측정값 + 합격 판정
{x}
+
{item.item}
+ {item.subItem &&
{item.subItem}
} +
+
{item.method}
+ {(item.method === "길이" || item.method === "폭") && ( +
{item.standard}
+ )} +
{val}8 + +
+
+ + {/* 범례 */} +
+
+ 비 고 + [범례] A : Accept, R : Reject, H : Hold +
+
+ 중량판정 + ■ 합 격 +
+
+
+ + {/* ── 결재란 ── */} + + + {/* ── 푸터 ── */} +
+
양식번호 : QF-805-2 (Rev.0)
+
A4(210mm×297mm)
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/screenMng/reportList/samples/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/samples/page.tsx new file mode 100644 index 00000000..3f30d56a --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/reportList/samples/page.tsx @@ -0,0 +1,102 @@ +"use client"; + +import Link from "next/link"; +import { ArrowLeft, ClipboardCheck, FileText, ShoppingCart } from "lucide-react"; + +const SAMPLES = [ + { + title: "검사 보고서", + titleEng: "Inspection Report", + description: "품질 검사 결과를 기록하고 관리하는 문서입니다. 검사 항목, 측정값, 합격/불합격 판정을 포함합니다.", + path: "/admin/screenMng/reportList/samples/inspection", + icon: ClipboardCheck, + docNo: "IR-2026-XXXX", + }, + { + title: "견적서", + titleEng: "Quotation", + description: "고객에게 제공하는 견적 문서입니다. 품목별 단가, 수량, 공급가액, 세액을 포함합니다.", + path: "/admin/screenMng/reportList/samples/quotation", + icon: FileText, + docNo: "QT-2026-XXXX", + }, + { + title: "발주서", + titleEng: "Purchase Order", + description: "공급업체에 발주하는 공식 문서입니다. 발주처 정보, 발주 내역, 납기일 등을 포함합니다.", + path: "/admin/screenMng/reportList/samples/purchase-order", + icon: ShoppingCart, + docNo: "PO-2026-XXXX", + }, +]; + +export default function SamplesPage() { + return ( +
+ {/* Header */} +
+
+ + + 리포트 목록 + +
+

리포트 디자인 샘플

+
+
+ +
+
+ {/* Title Section */} +
+

+ WACE PLM — 문서 양식 샘플 +

+

+ 리포트 디자이너에서 활용 가능한 표준 문서 양식 샘플입니다. +
+ 카드(정보패널), 테이블, 결재란 등 기본 컴포넌트로 구성되었습니다. +

+
+ + {/* Sample Cards */} +
+ {SAMPLES.map((sample) => ( + +
+ +

{sample.docNo}

+
+
+

+ {sample.title} +

+

{sample.titleEng}

+

+ {sample.description} +

+
+ + 샘플 보기 → + +
+
+ + ))} +
+ +
+

A4 인쇄 최적화 · WACE PLM 리포트 디자이너 v2.0

+
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/screenMng/reportList/samples/purchase-order/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/samples/purchase-order/page.tsx new file mode 100644 index 00000000..7e75181a --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/reportList/samples/purchase-order/page.tsx @@ -0,0 +1,218 @@ +"use client"; + +import DocumentLayout from "../components/DocumentLayout"; +import StatusBadge from "../components/StatusBadge"; + +const ITEMS = [ + { no: 1, code: "P-001", name: "원자재 A", spec: "KS-100", unit: "KG", qty: 500, price: 5000 }, + { no: 2, code: "P-002", name: "부품 B", spec: "ISO-200", unit: "EA", qty: 1000, price: 3000 }, + { no: 3, code: "P-003", name: "자재 C", spec: "JIS-300", unit: "M", qty: 200, price: 8000 }, +]; + +const EMPTY_ROWS = 10; + +// ── 발주처 정보 테이블 행 ───────────────────────────────────────────────────── + +function InfoRow({ + label, + children, + highlight, + colSpan, +}: { + label: string; + children?: React.ReactNode; + highlight?: boolean; + colSpan?: number; +}) { + const labelBg = highlight ? "bg-yellow-100" : "bg-[#EFF6FF]"; + return ( + <> + {label} + + {children} + + + ); +} + +export default function PurchaseOrderPage() { + const totalAmount = ITEMS.reduce((sum, item) => sum + item.qty * item.price, 0); + const tax = Math.round(totalAmount * 0.1); + const grandTotal = totalAmount + tax; + + return ( + +
+ {/* ── 헤더 ── */} +
+
+
+

발 주 서

+

PURCHASE ORDER

+
+
+ + {/* 결재란 인라인 */} +
+
+ {["담당", "부서장", "임원", "사장"].map((col, i) => ( +
+ {col} +
+ ))} +
+
+
+
+
+ + {/* ── 문서 번호 ── */} +
+
+
발주번호: PO-2026-00789
+
+
+ + {/* ── 발주처 정보 카드 ── */} +
+
+ ▣ 발주처 정보 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ TEL + 담당 +
+ + FAX +
+ TEL + 담당 +
+ + FAX +
+ TEL + 현장담당 +
+ + FAX +
20___년 ___월 ___일인도조건 +
+
+
+ + {/* ── 발주 내역 테이블 ── */} +
+
+ ▣ 발주 내역 +
+
+ + + + {["NO", "품 명", "규격", "단위", "수량", "단가", "금액", "비고"].map((h, i) => ( + + ))} + + + + {ITEMS.map((item, idx) => ( + + + + + + + + + + ))} + {Array.from({ length: EMPTY_ROWS }).map((_, idx) => ( + + + + ))} + +
+ {h} +
{item.no}{item.name}{item.spec}{item.unit}{item.qty.toLocaleString()}{item.price.toLocaleString()}{(item.qty * item.price).toLocaleString()} +
{ITEMS.length + idx + 1} + + + + + + +
+
+
+ + {/* ── 금액 요약 ── */} +
+ + + + + + + + + + + +
공급가액₩ {totalAmount.toLocaleString()}부가세액₩ {tax.toLocaleString()}합계금액₩ {grandTotal.toLocaleString()}
+
+ + {/* ── 안내문 ── */} +
+

상기 자재를 발주하오니 납기를 준수하여 인도 바랍니다.

+
+ + {/* ── 푸터 ── */} +
+
양식번호: PO-001 (Rev.2)
+
문의: TEL 000-0000-0000 / FAX 000-0000-0000
+
+
+
+ ); +} diff --git a/frontend/app/(main)/admin/screenMng/reportList/samples/quotation/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/samples/quotation/page.tsx new file mode 100644 index 00000000..46fef567 --- /dev/null +++ b/frontend/app/(main)/admin/screenMng/reportList/samples/quotation/page.tsx @@ -0,0 +1,204 @@ +"use client"; + +import DocumentLayout from "../components/DocumentLayout"; + +const ITEMS = [ + { no: 1, name: "프리미엄 제품 A", spec: "Model-X1000", qty: 50, unit: "EA", price: 150000 }, + { no: 2, name: "스탠다드 제품 B", spec: "Model-S500", qty: 100, unit: "EA", price: 80000 }, + { no: 3, name: "베이직 제품 C", spec: "Model-B200", qty: 200, unit: "EA", price: 45000 }, +]; + +const EMPTY_ROWS = 5; + +function ApprovalSection({ columns }: { columns: string[] }) { + return ( +
+
+
+ {columns.map((col, i) => ( +
+
{col}
+
+
+ ))} +
+
+
+ ); +} + +export default function QuotationPage() { + const supplyAmount = ITEMS.reduce((sum, item) => sum + item.qty * item.price, 0); + const tax = Math.round(supplyAmount * 0.1); + const total = supplyAmount + tax; + + return ( + +
+ {/* ── 헤더 ── */} +
+
+

견 적 서

+

QUOTATION

+
+
+ + {/* ── 문서 번호 ── */} +
+
+
문서번호: QT-2026-01234
+
+
+ + {/* ── 날짜 / 수신 ── */} +
+
+
+ 2026 + + 03 + + 09 + +
+
+ (주) ○○○○ + 귀하 +
+
+
+ + {/* ── 견적명 / 공급자 (2열 카드) ── */} +
+
+
+ 견 적 명 +
+
+
+
+
+ 공 급 자 +
+
+
+ 등록번호 + 상호(법인명) / 성명 +
+
+ 업태 / 업종 + 주소 +
+
+ 전화번호       팩스 +
+
+
+
+ + {/* ── 합계금액 ── */} +
+
합 계 금 액
+
+ ₩ {total.toLocaleString()} 원정 +
+
+ + {/* ── 품목 테이블 ── */} +
+ + + + {["품번", "품명", "규격", "수량", "단가", "공급가액", "세액", "비고"].map((h, i) => ( + + ))} + + + + {ITEMS.map((item, idx) => { + const amount = item.qty * item.price; + const itemTax = Math.round(amount * 0.1); + return ( + + + + + + + + + + ); + })} + {Array.from({ length: EMPTY_ROWS }).map((_, idx) => ( + + + + ))} + {/* 합계 행 */} + + + + + + +
{h}
{item.no}{item.name}{item.spec}{item.qty.toLocaleString()}{item.price.toLocaleString()}{amount.toLocaleString()}{itemTax.toLocaleString()} +
{ITEMS.length + idx + 1} + + + + + + +
합 계 + + {supplyAmount.toLocaleString()}{tax.toLocaleString()} +
+
+ + {/* ── 금액 요약 (우측 정렬) ── */} +
+
+ + + + + + + + + + + + + + + +
공급가액₩ {supplyAmount.toLocaleString()}
부가세 (10%)₩ {tax.toLocaleString()}
합계금액₩ {total.toLocaleString()}
+
+
+ + {/* ── 안내문 ── */} +
+

위와 같이 견적합니다.

+

상기 견적서의 품목과 금액을 확인해 주시기 바랍니다.

+

감사합니다.

+
+ + {/* ── 결재란 ── */} + + + {/* ── 푸터 ── */} +
+
+
본 견적서의 유효기간은 견적일로부터 7일입니다.
+
+
+
결제계좌: (예금주: )
+
문의: TEL 000-0000-0000 / FAX 000-0000-0000
+
+
+
+ + ); +} diff --git a/frontend/components/common/UnsavedChangesGuard.tsx b/frontend/components/common/UnsavedChangesGuard.tsx new file mode 100644 index 00000000..b255d3cb --- /dev/null +++ b/frontend/components/common/UnsavedChangesGuard.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogCancel, + AlertDialogAction, +} from "@/components/ui/alert-dialog"; + +interface UseUnsavedChangesGuardOptions { + hasChanges: () => boolean; + onClose: () => void; + title?: string; + description?: string; +} + +interface UnsavedChangesGuard { + handleOpenChange: (open: boolean) => void; + tryClose: () => void; + doClose: () => void; + showDialog: boolean; + confirmClose: () => void; + cancelClose: () => void; + title: string; + description: string; +} + +export function useUnsavedChangesGuard({ + hasChanges, + onClose, + title = "변경사항이 있습니다", + description = "저장하지 않은 변경사항이 사라집니다. 정말 닫으시겠습니까?", +}: UseUnsavedChangesGuardOptions): UnsavedChangesGuard { + const [showDialog, setShowDialog] = useState(false); + const hasChangesRef = useRef(hasChanges); + hasChangesRef.current = hasChanges; + + const attemptClose = useCallback(() => { + if (hasChangesRef.current()) { + setShowDialog(true); + } else { + onClose(); + } + }, [onClose]); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + attemptClose(); + } + }, + [attemptClose], + ); + + const confirmClose = useCallback(() => { + setShowDialog(false); + onClose(); + }, [onClose]); + + const cancelClose = useCallback(() => { + setShowDialog(false); + }, []); + + const doClose = useCallback(() => { + setShowDialog(false); + onClose(); + }, [onClose]); + + return { + handleOpenChange, + tryClose: attemptClose, + doClose, + showDialog, + confirmClose, + cancelClose, + title, + description, + }; +} + +interface UnsavedChangesDialogProps { + guard: UnsavedChangesGuard; +} + +export function UnsavedChangesDialog({ guard }: UnsavedChangesDialogProps) { + return ( + !open && guard.cancelClose()}> + + + + {guard.title} + + + {guard.description} + + + + + 취소 + + + 닫기 + + + + + ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 1b379b5d..ba0d9959 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -360,7 +360,7 @@ function DynamicAdminLoader({ url, params }: { url: string; params?: Record; if (!Component) return ; - if (params) return ; + if (params) return ; return ; } diff --git a/frontend/components/report/ReportCopyModal.tsx b/frontend/components/report/ReportCopyModal.tsx new file mode 100644 index 00000000..75dca97f --- /dev/null +++ b/frontend/components/report/ReportCopyModal.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; +import { ReportMaster } from "@/types/report"; +import { reportApi } from "@/lib/api/reportApi"; +import { useToast } from "@/hooks/use-toast"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; + +interface ReportCopyModalProps { + report: ReportMaster; + onClose: () => void; + onSuccess: () => void; +} + +export function ReportCopyModal({ report, onClose, onSuccess }: ReportCopyModalProps) { + const [newName, setNewName] = useState(`${report.report_name_kor} (복사)`); + const [isCopying, setIsCopying] = useState(false); + const initialNameRef = useRef(`${report.report_name_kor} (복사)`); + const { toast } = useToast(); + + const guard = useUnsavedChangesGuard({ + hasChanges: () => !isCopying && newName !== initialNameRef.current, + onClose, + title: "입력된 내용이 있습니다", + description: "입력된 내용이 저장되지 않습니다. 정말 닫으시겠습니까?", + }); + + const handleCopy = async () => { + const trimmed = newName.trim(); + if (!trimmed) { + toast({ title: "오류", description: "리포트 이름을 입력해주세요.", variant: "destructive" }); + return; + } + + setIsCopying(true); + try { + const response = await reportApi.copyReport(report.report_id, trimmed); + if (response.success) { + toast({ title: "성공", description: "리포트가 복사되었습니다." }); + onSuccess(); + } + } catch (error: any) { + toast({ + title: "오류", + description: error.message || "리포트 복사에 실패했습니다.", + variant: "destructive", + }); + } finally { + setIsCopying(false); + } + }; + + return ( + <> + + + + 리포트 복사 + +
+
+ + setNewName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !isCopying && handleCopy()} + placeholder="리포트 이름 입력" + className="h-11 text-base" + autoFocus + /> +
+
+ + + + +
+
+ + + + ); +} diff --git a/frontend/components/report/ReportCreateModal.tsx b/frontend/components/report/ReportCreateModal.tsx index c51dd982..61203a6e 100644 --- a/frontend/components/report/ReportCreateModal.tsx +++ b/frontend/components/report/ReportCreateModal.tsx @@ -1,22 +1,46 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; +import { useRouter } from "next/navigation"; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, DialogFooter, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; +import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard"; 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 { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Loader2, LayoutTemplate, Check, ChevronsUpDown, Plus, Tag } from "lucide-react"; import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; -import { CreateReportRequest, ReportTemplate } from "@/types/report"; +import { REPORT_TYPE_OPTIONS, getTypeIcon, getTypeLabel } from "@/lib/reportTypeColors"; +import { ReportTemplate } from "@/types/report"; +import { cn } from "@/lib/utils"; interface ReportCreateModalProps { isOpen: boolean; @@ -24,59 +48,137 @@ interface ReportCreateModalProps { onSuccess: () => void; } +const TEMPLATE_NONE = "__none__"; + export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateModalProps) { - const [formData, setFormData] = useState({ - reportNameKor: "", - reportNameEng: "", - templateId: undefined, - reportType: "BASIC", - description: "", - }); - const [templates, setTemplates] = useState([]); + const router = useRouter(); + const [reportName, setReportName] = useState(""); + const [reportType, setReportType] = useState(""); + const [customCategory, setCustomCategory] = useState(""); + const [categoryOpen, setCategoryOpen] = useState(false); + const [description, setDescription] = useState(""); + const [selectedTemplateId, setSelectedTemplateId] = useState(TEMPLATE_NONE); const [isLoading, setIsLoading] = useState(false); const [isLoadingTemplates, setIsLoadingTemplates] = useState(false); + const [isLoadingCategories, setIsLoadingCategories] = useState(false); + const [systemTemplates, setSystemTemplates] = useState([]); + const [customTemplates, setCustomTemplates] = useState([]); + const [existingCategories, setExistingCategories] = useState([]); const { toast } = useToast(); - // 템플릿 목록 불러오기 useEffect(() => { - if (isOpen) { - fetchTemplates(); - } + if (!isOpen) return; + + const fetchTemplates = async () => { + setIsLoadingTemplates(true); + try { + const response = await reportApi.getTemplates(); + if (response.success && response.data) { + setSystemTemplates(response.data.system || []); + setCustomTemplates(response.data.custom || []); + } + } catch { + // 템플릿 로딩 실패 시 빈 목록으로 진행 + } finally { + setIsLoadingTemplates(false); + } + }; + + const fetchCategories = async () => { + setIsLoadingCategories(true); + try { + const response = await reportApi.getCategories(); + if (response.success && response.data) { + setExistingCategories(response.data); + } + } catch { + // 카테고리 로딩 실패 시 빈 목록으로 진행 + } finally { + setIsLoadingCategories(false); + } + }; + + fetchTemplates(); + fetchCategories(); }, [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 hasTemplates = useMemo( + () => systemTemplates.length > 0 || customTemplates.length > 0, + [systemTemplates, customTemplates], + ); + + const allCategories = useMemo(() => { + const defaultTypes = REPORT_TYPE_OPTIONS.map((opt) => opt.value); + const merged = new Set([...defaultTypes, ...existingCategories]); + return Array.from(merged).sort(); + }, [existingCategories]); + + const effectiveCategory = useMemo(() => { + return customCategory.trim() || reportType; + }, [customCategory, reportType]); + + const categoryDisplayLabel = useMemo(() => { + if (customCategory.trim()) return customCategory.trim(); + if (reportType) return getTypeLabel(reportType); + return ""; + }, [customCategory, reportType]); + + const hasInputData = useCallback(() => { + return reportName.trim() !== "" || + reportType !== "" || + customCategory.trim() !== "" || + description.trim() !== "" || + selectedTemplateId !== TEMPLATE_NONE; + }, [reportName, reportType, customCategory, description, selectedTemplateId]); + + const resetForm = useCallback(() => { + setReportName(""); + setReportType(""); + setCustomCategory(""); + setDescription(""); + setSelectedTemplateId(TEMPLATE_NONE); + }, []); + + const guard = useUnsavedChangesGuard({ + hasChanges: () => !isLoading && hasInputData(), + onClose: () => { + resetForm(); + onClose(); + }, + title: "입력된 내용이 있습니다", + description: "입력된 내용이 저장되지 않습니다. 정말 닫으시겠습니까?", + }); + + const handleCategorySelect = (value: string) => { + setReportType(value); + setCustomCategory(""); + setCategoryOpen(false); + }; + + const handleCustomCategoryAdd = () => { + const trimmed = customCategory.trim(); + if (trimmed) { + setReportType(""); + setCategoryOpen(false); } }; const handleSubmit = async () => { - // 유효성 검증 - if (!formData.reportNameKor.trim()) { + const trimmed = reportName.trim(); + if (!trimmed) { toast({ title: "입력 오류", - description: "리포트명(한글)을 입력해주세요.", + description: "리포트명을 입력해주세요.", variant: "destructive", }); return; } - if (!formData.reportType) { + const finalCategory = effectiveCategory; + if (!finalCategory) { toast({ title: "입력 오류", - description: "리포트 타입을 선택해주세요.", + description: "카테고리를 선택하거나 입력해주세요.", variant: "destructive", }); return; @@ -84,144 +186,223 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo 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", + const response = await reportApi.createReport({ + reportNameKor: trimmed, + reportType: finalCategory, + description: description.trim() || undefined, + templateId: selectedTemplateId !== TEMPLATE_NONE ? selectedTemplateId : undefined, }); + + if (response.success && response.data) { + toast({ title: "성공", description: "리포트가 생성되었습니다." }); + guard.doClose(); + onSuccess(); + router.push(`/admin/screenMng/reportList/designer/${response.data.reportId}`); + } + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : "리포트 생성에 실패했습니다."; + toast({ title: "오류", description: msg, variant: "destructive" }); } finally { setIsLoading(false); } }; - const handleClose = () => { - setFormData({ - reportNameKor: "", - reportNameEng: "", - templateId: undefined, - reportType: "BASIC", - description: "", - }); - onClose(); - }; - return ( - - - - 새 리포트 생성 - 새로운 리포트를 생성합니다. 필수 항목을 입력해주세요. - + <> + + + + 새 리포트 생성 + + 리포트명과 카테고리를 입력한 후 디자이너에서 상세 설계를 진행합니다. + + -
- {/* 리포트명 (한글) */} -
- - setFormData({ ...formData, reportNameKor: e.target.value })} - /> -
+
+
+ + setReportName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !isLoading && handleSubmit()} + className="h-11 text-base" + autoFocus + /> +
- {/* 리포트명 (영문) */} -
- - setFormData({ ...formData, reportNameEng: e.target.value })} - /> -
+
+ + + + + + + + + + {customCategory.trim() && !allCategories.includes(customCategory.trim()) && ( + + + + + "{customCategory.trim()}" 새로 추가 + + + + )} + + 일치하는 카테고리가 없습니다. +
+ 위에 입력한 값으로 새 카테고리를 추가할 수 있습니다. +
+ + {allCategories.map((cat) => { + const Icon = getTypeIcon(cat); + const label = getTypeLabel(cat); + return ( + handleCategorySelect(cat)} + className="text-base" + > + + + {label} + {cat !== label && ( + ({cat}) + )} + + ); + })} + +
+
+
+
+

+ 기존 카테고리를 선택하거나 새로운 카테고리를 직접 입력할 수 있습니다. +

+
- {/* 템플릿 선택 */} -
- - + + + + + + 템플릿 없이 시작 - ))} - - + {systemTemplates.length > 0 && ( + <> +
시스템 템플릿
+ {systemTemplates.map((t) => ( + +
+ + {t.template_name_kor} +
+
+ ))} + + )} + {customTemplates.length > 0 && ( + <> +
사용자 템플릿
+ {customTemplates.map((t) => ( + +
+ + {t.template_name_kor} +
+
+ ))} + + )} + {!isLoadingTemplates && !hasTemplates && ( +
등록된 템플릿이 없습니다
+ )} + + +

템플릿을 선택하면 레이아웃이 자동으로 적용됩니다.

+
+ +
+ +