From 930727a5cbdd20b02de9645aedd6d99332b8f632 Mon Sep 17 00:00:00 2001 From: shin Date: Thu, 12 Mar 2026 18:47:42 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/AGENT_SKILLS_MAP.md | 85 + .cursor/agents/code-reviewer.md | 54 + .cursor/agents/debugger.md | 51 + .cursor/agents/pm.md | 58 + .cursor/agents/web-verifier.md | 37 + .cursor/plans/large-file-refactoring-plan.md | 374 ++ .../리포트_컴포넌트화_Phase3_확장_계획서.md | 568 +++ .cursor/rules/modal-design.mdc | 18 + .cursor/rules/web-verify-login.mdc | 24 + .cursor/skills/code-fix/SKILL.md | 34 + .cursor/skills/code-review/SKILL.md | 43 + .cursor/skills/component-dev/SKILL.md | 47 + .cursor/skills/component-registry/SKILL.md | 48 + .../skills/component-registry/reference.md | 96 + .cursor/skills/github/SKILL.md | 38 + .cursor/skills/implement/SKILL.md | 37 + .cursor/skills/next-feature/SKILL.md | 37 + .cursor/skills/notion-writing/SKILL.md | 572 +++ .cursor/skills/plan/SKILL.md | 46 + .cursor/skills/react-component/SKILL.md | 51 + .cursor/skills/table-sql/SKILL.md | 46 + .cursor/skills/ui-debugging/SKILL.md | 55 + .cursor/skills/ui-debugging/reference.md | 70 + .cursor/skills/web-verify/SKILL.md | 70 + .../src/controllers/reportController.ts | 490 +-- backend-node/src/routes/reportRoutes.ts | 21 + backend-node/src/services/reportService.ts | 1569 +++++---- backend-node/src/types/report.ts | 194 +- cursor-rules-backup-20260309.tar.gz | Bin 0 -> 51923 bytes db/migrations/RUN_MIGRATION_1004.md | 17 + .../reportList/designer/[reportId]/page.tsx | 110 +- .../admin/screenMng/reportList/page.tsx | 25 +- .../samples/components/DocumentLayout.tsx | 57 + .../samples/components/StatusBadge.tsx | 31 + .../reportList/samples/inspection/page.tsx | 207 ++ .../screenMng/reportList/samples/page.tsx | 102 + .../samples/purchase-order/page.tsx | 218 ++ .../reportList/samples/quotation/page.tsx | 204 ++ .../components/common/UnsavedChangesGuard.tsx | 116 + .../components/layout/AdminPageRenderer.tsx | 2 +- .../components/report/ReportCopyModal.tsx | 102 + .../components/report/ReportCreateModal.tsx | 503 ++- .../report/ReportListPreviewModal.tsx | 587 ++++ .../components/report/ReportListTable.tsx | 644 +++- .../report/designer/CanvasComponent.tsx | 1359 ++------ .../report/designer/ComponentPalette.tsx | 123 +- .../report/designer/GridSettingsPanel.tsx | 8 +- .../report/designer/MenuSelectModal.tsx | 176 +- .../report/designer/PageListPanel.tsx | 265 +- .../report/designer/PageSettingsTab.tsx | 423 +++ .../report/designer/QueryManager.tsx | 36 +- .../report/designer/ReportDesignerCanvas.tsx | 269 +- .../designer/ReportDesignerLeftPanel.tsx | 120 +- .../designer/ReportDesignerRightPanel.tsx | 3058 +---------------- .../report/designer/ReportDesignerToolbar.tsx | 486 ++- .../report/designer/ReportPreviewModal.tsx | 1393 +++----- frontend/components/report/designer/Ruler.tsx | 18 +- .../report/designer/SaveAsTemplateModal.tsx | 191 +- .../report/designer/SignatureGenerator.tsx | 4 +- .../report/designer/SignaturePad.tsx | 4 +- .../report/designer/TemplatePalette.tsx | 15 +- .../report/designer/WatermarkLayer.tsx | 178 + .../designer/modals/CardCanvasEditor.tsx | 1204 +++++++ .../designer/modals/CardElementPalette.tsx | 74 + .../designer/modals/CardLayoutModal.tsx | 600 ++++ .../report/designer/modals/CardLayoutTabs.tsx | 487 +++ .../designer/modals/CardPreviewPanel.tsx | 46 + .../designer/modals/ComponentPreviewPanel.tsx | 156 + .../modals/ComponentSettingsModal.tsx | 230 ++ .../modals/ConditionalSettingsTab.tsx | 23 + .../designer/modals/FooterAggregateModal.tsx | 136 + .../designer/modals/GridCellDropZone.tsx | 277 ++ .../report/designer/modals/GridEditor.tsx | 897 +++++ .../designer/modals/ImageLayoutTabs.tsx | 362 ++ .../designer/modals/QuerySettingsTab.tsx | 30 + .../designer/modals/SettingsModalShell.tsx | 192 ++ .../designer/modals/TableCanvasEditor.tsx | 256 ++ .../designer/modals/TableColumnDropZone.tsx | 224 ++ .../designer/modals/TableColumnPalette.tsx | 246 ++ .../designer/modals/TableLayoutTabs.tsx | 1104 ++++++ .../designer/modals/TablePreviewPanel.tsx | 104 + .../report/designer/modals/TextLayoutTabs.tsx | 163 + .../modals/VisualDataSourceBuilder.tsx | 696 ++++ .../report/designer/modals/shared.tsx | 203 ++ .../designer/properties/BarcodeProperties.tsx | 358 ++ .../properties/CalculationProperties.tsx | 344 ++ .../designer/properties/CardProperties.tsx | 256 ++ .../properties/CheckboxProperties.tsx | 167 + .../properties/ComponentStylePanel.tsx | 606 ++++ .../properties/ConditionalProperties.tsx | 520 +++ .../designer/properties/DividerProperties.tsx | 193 ++ .../designer/properties/ImageProperties.tsx | 218 ++ .../properties/MultiSelectProperties.tsx | 206 ++ .../properties/PageNumberProperties.tsx | 44 + .../properties/SignatureProperties.tsx | 285 ++ .../designer/properties/TableProperties.tsx | 262 ++ .../designer/properties/TextProperties.tsx | 449 +++ .../properties/VisualQueryBuilder.tsx | 441 +++ .../renderers/BarcodeCanvasRenderer.tsx | 230 ++ .../renderers/CalculationRenderer.tsx | 108 + .../designer/renderers/CardRenderer.tsx | 616 ++++ .../designer/renderers/CheckboxRenderer.tsx | 64 + .../designer/renderers/DividerRenderer.tsx | 44 + .../designer/renderers/ImageRenderer.tsx | 131 + .../designer/renderers/PageNumberRenderer.tsx | 40 + .../designer/renderers/SignatureRenderer.tsx | 103 + .../designer/renderers/TableRenderer.tsx | 322 ++ .../designer/renderers/TextRenderer.tsx | 85 + .../report/designer/renderers/index.ts | 11 + .../report/designer/renderers/types.ts | 48 + frontend/contexts/ReportDesignerContext.tsx | 2110 ++---------- .../contexts/report-designer/internalTypes.ts | 15 + frontend/contexts/report-designer/types.ts | 27 + .../report-designer/useAlignmentActions.ts | 192 ++ .../report-designer/useClipboardActions.ts | 302 ++ .../report-designer/useHistoryManager.ts | 113 + .../report-designer/useLayerActions.ts | 198 ++ .../contexts/report-designer/useLayoutIO.ts | 402 +++ .../report-designer/usePageManager.ts | 228 ++ .../report-designer/useQueryManager.ts | 46 + .../report-designer/useSelectionState.ts | 78 + .../contexts/report-designer/useUIState.ts | 200 ++ frontend/hooks/useReportList.ts | 79 +- frontend/lib/api/reportApi.ts | 78 +- .../ReportViewerComponent.tsx | 133 + .../ReportViewerConfigPanel.tsx | 115 + .../v2-report-viewer/ReportViewerRenderer.tsx | 11 + .../components/v2-report-viewer/index.ts | 28 + .../components/v2-report-viewer/types.ts | 18 + frontend/lib/report/conditionalUtils.ts | 79 + frontend/lib/report/constants.ts | 26 + frontend/lib/report/queryUtils.ts | 94 + frontend/lib/reportTypeColors.ts | 76 + frontend/package-lock.json | 10 + frontend/package.json | 1 + frontend/types/report.ts | 562 ++- frontend/update_templates.js | 17 + reportdocs/PHASE10_CURSOR.md | 0 reportdocs/PLAN.md | 1153 +++++++ reportdocs/REPORT_TEMPLATES_TODO.md | 214 ++ 140 files changed, 27027 insertions(+), 9323 deletions(-) create mode 100644 .cursor/AGENT_SKILLS_MAP.md create mode 100644 .cursor/agents/code-reviewer.md create mode 100644 .cursor/agents/debugger.md create mode 100644 .cursor/agents/pm.md create mode 100644 .cursor/agents/web-verifier.md create mode 100644 .cursor/plans/large-file-refactoring-plan.md create mode 100644 .cursor/plans/리포트_컴포넌트화_Phase3_확장_계획서.md create mode 100644 .cursor/rules/modal-design.mdc create mode 100644 .cursor/rules/web-verify-login.mdc create mode 100644 .cursor/skills/code-fix/SKILL.md create mode 100644 .cursor/skills/code-review/SKILL.md create mode 100644 .cursor/skills/component-dev/SKILL.md create mode 100644 .cursor/skills/component-registry/SKILL.md create mode 100644 .cursor/skills/component-registry/reference.md create mode 100644 .cursor/skills/github/SKILL.md create mode 100644 .cursor/skills/implement/SKILL.md create mode 100644 .cursor/skills/next-feature/SKILL.md create mode 100644 .cursor/skills/notion-writing/SKILL.md create mode 100644 .cursor/skills/plan/SKILL.md create mode 100644 .cursor/skills/react-component/SKILL.md create mode 100644 .cursor/skills/table-sql/SKILL.md create mode 100644 .cursor/skills/ui-debugging/SKILL.md create mode 100644 .cursor/skills/ui-debugging/reference.md create mode 100644 .cursor/skills/web-verify/SKILL.md create mode 100644 cursor-rules-backup-20260309.tar.gz create mode 100644 db/migrations/RUN_MIGRATION_1004.md create mode 100644 frontend/app/(main)/admin/screenMng/reportList/samples/components/DocumentLayout.tsx create mode 100644 frontend/app/(main)/admin/screenMng/reportList/samples/components/StatusBadge.tsx create mode 100644 frontend/app/(main)/admin/screenMng/reportList/samples/inspection/page.tsx create mode 100644 frontend/app/(main)/admin/screenMng/reportList/samples/page.tsx create mode 100644 frontend/app/(main)/admin/screenMng/reportList/samples/purchase-order/page.tsx create mode 100644 frontend/app/(main)/admin/screenMng/reportList/samples/quotation/page.tsx create mode 100644 frontend/components/common/UnsavedChangesGuard.tsx create mode 100644 frontend/components/report/ReportCopyModal.tsx create mode 100644 frontend/components/report/ReportListPreviewModal.tsx create mode 100644 frontend/components/report/designer/PageSettingsTab.tsx create mode 100644 frontend/components/report/designer/WatermarkLayer.tsx create mode 100644 frontend/components/report/designer/modals/CardCanvasEditor.tsx create mode 100644 frontend/components/report/designer/modals/CardElementPalette.tsx create mode 100644 frontend/components/report/designer/modals/CardLayoutModal.tsx create mode 100644 frontend/components/report/designer/modals/CardLayoutTabs.tsx create mode 100644 frontend/components/report/designer/modals/CardPreviewPanel.tsx create mode 100644 frontend/components/report/designer/modals/ComponentPreviewPanel.tsx create mode 100644 frontend/components/report/designer/modals/ComponentSettingsModal.tsx create mode 100644 frontend/components/report/designer/modals/ConditionalSettingsTab.tsx create mode 100644 frontend/components/report/designer/modals/FooterAggregateModal.tsx create mode 100644 frontend/components/report/designer/modals/GridCellDropZone.tsx create mode 100644 frontend/components/report/designer/modals/GridEditor.tsx create mode 100644 frontend/components/report/designer/modals/ImageLayoutTabs.tsx create mode 100644 frontend/components/report/designer/modals/QuerySettingsTab.tsx create mode 100644 frontend/components/report/designer/modals/SettingsModalShell.tsx create mode 100644 frontend/components/report/designer/modals/TableCanvasEditor.tsx create mode 100644 frontend/components/report/designer/modals/TableColumnDropZone.tsx create mode 100644 frontend/components/report/designer/modals/TableColumnPalette.tsx create mode 100644 frontend/components/report/designer/modals/TableLayoutTabs.tsx create mode 100644 frontend/components/report/designer/modals/TablePreviewPanel.tsx create mode 100644 frontend/components/report/designer/modals/TextLayoutTabs.tsx create mode 100644 frontend/components/report/designer/modals/VisualDataSourceBuilder.tsx create mode 100644 frontend/components/report/designer/modals/shared.tsx create mode 100644 frontend/components/report/designer/properties/BarcodeProperties.tsx create mode 100644 frontend/components/report/designer/properties/CalculationProperties.tsx create mode 100644 frontend/components/report/designer/properties/CardProperties.tsx create mode 100644 frontend/components/report/designer/properties/CheckboxProperties.tsx create mode 100644 frontend/components/report/designer/properties/ComponentStylePanel.tsx create mode 100644 frontend/components/report/designer/properties/ConditionalProperties.tsx create mode 100644 frontend/components/report/designer/properties/DividerProperties.tsx create mode 100644 frontend/components/report/designer/properties/ImageProperties.tsx create mode 100644 frontend/components/report/designer/properties/MultiSelectProperties.tsx create mode 100644 frontend/components/report/designer/properties/PageNumberProperties.tsx create mode 100644 frontend/components/report/designer/properties/SignatureProperties.tsx create mode 100644 frontend/components/report/designer/properties/TableProperties.tsx create mode 100644 frontend/components/report/designer/properties/TextProperties.tsx create mode 100644 frontend/components/report/designer/properties/VisualQueryBuilder.tsx create mode 100644 frontend/components/report/designer/renderers/BarcodeCanvasRenderer.tsx create mode 100644 frontend/components/report/designer/renderers/CalculationRenderer.tsx create mode 100644 frontend/components/report/designer/renderers/CardRenderer.tsx create mode 100644 frontend/components/report/designer/renderers/CheckboxRenderer.tsx create mode 100644 frontend/components/report/designer/renderers/DividerRenderer.tsx create mode 100644 frontend/components/report/designer/renderers/ImageRenderer.tsx create mode 100644 frontend/components/report/designer/renderers/PageNumberRenderer.tsx create mode 100644 frontend/components/report/designer/renderers/SignatureRenderer.tsx create mode 100644 frontend/components/report/designer/renderers/TableRenderer.tsx create mode 100644 frontend/components/report/designer/renderers/TextRenderer.tsx create mode 100644 frontend/components/report/designer/renderers/index.ts create mode 100644 frontend/components/report/designer/renderers/types.ts create mode 100644 frontend/contexts/report-designer/internalTypes.ts create mode 100644 frontend/contexts/report-designer/types.ts create mode 100644 frontend/contexts/report-designer/useAlignmentActions.ts create mode 100644 frontend/contexts/report-designer/useClipboardActions.ts create mode 100644 frontend/contexts/report-designer/useHistoryManager.ts create mode 100644 frontend/contexts/report-designer/useLayerActions.ts create mode 100644 frontend/contexts/report-designer/useLayoutIO.ts create mode 100644 frontend/contexts/report-designer/usePageManager.ts create mode 100644 frontend/contexts/report-designer/useQueryManager.ts create mode 100644 frontend/contexts/report-designer/useSelectionState.ts create mode 100644 frontend/contexts/report-designer/useUIState.ts create mode 100644 frontend/lib/registry/components/v2-report-viewer/ReportViewerComponent.tsx create mode 100644 frontend/lib/registry/components/v2-report-viewer/ReportViewerConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-report-viewer/ReportViewerRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-report-viewer/index.ts create mode 100644 frontend/lib/registry/components/v2-report-viewer/types.ts create mode 100644 frontend/lib/report/conditionalUtils.ts create mode 100644 frontend/lib/report/constants.ts create mode 100644 frontend/lib/report/queryUtils.ts create mode 100644 frontend/lib/reportTypeColors.ts create mode 100644 frontend/update_templates.js create mode 100644 reportdocs/PHASE10_CURSOR.md create mode 100644 reportdocs/PLAN.md create mode 100644 reportdocs/REPORT_TEMPLATES_TODO.md 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 0000000000000000000000000000000000000000..5f8eeb10b9dadcd126b4db463077213380bf09e2 GIT binary patch literal 51923 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�~yZC(57A>05lKG86qAIW9PmhP>ParY;#R49hqW6kYA^404NPP(>EBWW^!* zF^iK-kBy_xQP~?H4f#OUt7LLA!;+lIlZQ;#+(2ZGV~4aB0EH!)hz>1_Dliv zg^mj{_c}A4PWbkgGD^C2-?E1`<7sfs0O(C&tpqd>7yji|n}*u(M@-4pd+rBJ)ZDpXYo zQIVCDXPcW;BB|C66|ys?Y`3&AooH&7YD@{?rSA?4X?yLy?~VA! z-_}7PdRFbgL@`7MX{g81fj66pEr2lk;gXlfyhYS|8G3-O%YJJadpgNrhDsTq^eHvT z{^z#I(C&k|!l|~#b39CeC;gKQJ@o^&;fCns(P)z)o+)p^nn}R6>rjpOx2e;XrM<7S z%(lT$;{|W;Q0^mUwKiK_UwN~Xz|6dwWt1;vs1KrWz>3ygpJ$pO>j{$x{d2FSI`d<= za+894mmk*~DcWheqzrYB<$A72Y22@B&nr&T-8OJV$i_-oxMYXl0Z!ON>9Lqg1xIm| zBwcVv)B)pYKI$I|7Um42GW>?RB)BJ&ZItgj^EynA)O$a&cj$2j_Cc_UeF9k(OWsMZ zh=U@t23X=l>Iy}PB^isa6Alf(oXz_L1(FAd3<=-D+9H4 zftrdNzn(!g0P*NJBHPG9-=SF4T6e3c7}?U%yS@*XG-~BwCfY33k5X)+>VN8(tDX&= zd3HDTA(wnOwKsM)qzXEd+NDu2r;Y2vM=OIIgc-eC=CXnQ8wpJyG?g25jzcg=A6V)a zatJ;~+1R7u(Q;LSco}t_sd5H<dtVExAg_I~z^=3s@pQ1hgf$M_zVIR@F=5C?ym-P4pHOSc?}Tva zJX$%*2!&$f#)FN|o`x^Jvuln?(RmE%E-iB+guKV;jqTHgU5v3te6)pS2l;7;~xaIPx0}q@a@}#Si8kXwX#+SJQa9|}bYcsdP z;^l$8C22=2D#Ig-D0{g&P`oplp$f3bjN@T2rv~0Wt9v$;wnFCVsZ>B3PNXih%dKpU zx-td zNi7LIoJn~%mk;J=MOJpy|U^=^l7@!y~-2kNY75(Skz#T zP9}kwNa{DyxD%pQ#(WJ6T1@lHpz-Iv6s>|fdx5K@?r82BHN@nYK;c-Y2B>t}7n)SE zJM_-HlI!5U)|lWc7h1{{JVjK1xqXVU+GkrL9_vh%s^oIBuZW}+4Gva~8{y|@e<@Qa zv!oQ@sN>VuO3d={b})|shoYa1Xw0mAKQ6ewcJ6Z!fM#!Buq&}JWN|Qv!{f3ObB7C? zU)e!!caOB~J!^MoYIid{G6$F;sc=eco-dIXc+f*ZE7E8>Z38hAjFiHKDBXW40FDbI zZGc$bTWpEgw%@pgdGOu-w;;N)>T%^11K7eTGuWLjWh?mjTL8rn3>oBi8A0W zbLV=T{Niq|S?zP=BQGm`FfspKo+gwvar#%cu zK})F`JWgRU>`EEqAi-_bT#@;s8b5xDv77FLr}RZqp|?4K@FQ_j)!h}bUx#kGXWeA> zbpFTKWCkO)y2bQ~5aZx>GCMCSU`Rv5r$mD*biR069^YXHz4h8Y-POg`I^m7cod3EI zIPy6i$YSW)w9ky8r5&*W?{D=6b;2+6 zb54HgWx%&HGQYDQ*wL8$97m|fO(66V#q}lQxT|p2P{LjK{jt!mI}6#ae6h?Km1L~h zFQ;YB&}QX=aP^-#QjuHnP;nwqPr4E?&PbUu94y#nam1-DBR2W~s2EIkz({VlUMMg( zU-9Rcs^9z|lR(SoYr=f z0PlC_X(!BoOYx#C7^~Ry)!WLhj6#7~M;5wn-+l@dL#3|t1ud80aZ)HhwS<$PF7ONE zhm^wntFobO)R6ClqSrVaeO z%IfT(>QX6oNZq9B7vD=4R%2JID&E+nS*~dIs;n5XU1Ak5-yJKg2yWEAiZ>=ty^tc$ z#>mgb9Q3;$G4}#($RQwQ|Sh45~~;x0u5kD6(PclPGgNVdu3bVHtWhIiI%#W+n(^ zB}T20$#iUIl|D`?D?1GZgQ+gfVo<%`VAe&M(SZc8!MeIRS@|vHTHtug25X@t=qP$_ zQazuIiIuvVIP;K!y0Y^aGr+Z_PvW-UyPP9=28QQG()_*=q)R_#i{+>CW_f_y*VXFg zg4Wejsb=_(Wz=;`+aLo%eVQl(uQ|Lx9T4I5@WSHDb#1+Uj%?SXla?GjVXYO)19WOx zC}=P-qLX5yqseVIxdB=0DVOn%;OeA$4j=unUj-GGVKl8FB($KKCa0s$tdbofr;L!P zBhSie@NJ4S=g@^4jS8a0g1L;K#=2PO1^yF54@OVO(93dCCCqx$YRiFw`=v3)M#ss- zJ||aEt~o^zO-Lbig?!}obuVFS?&!xMxSG=!VD~kGln{i2+XbJsAR)0`Vxo;GKM?)| z40WLUSoh)nKKx1{d-kJ+c*faD2nCQ}2q-^wK1`EkGEk_M2&^4m^u{tMbF(7%Q`ADG zYqFOL!_S9j3Ydy)B`B-EZ&p}%?%x%>sG2U-~7q6KR##(*x*|iH zZ*$~%yb75zNo_6BrXwrV*7s(pF)h()BuIRgX4x538wZ*l0GIK)3L=e=TSB}2E||?^ z?nZ_tZ48Xbr+Gwzcgu#&_D&8{%IKQ5ggQhX&!PgB4A~DcHiD`q2@tyGNr}7<#Yrrm zBkA8>M<8z1+*}}!pZY@iQ{JQ>dLywswb?)pZ%g1UM5Jn#wB9NcE^Bu!;nWmqF{HEA zUvMMu?}k6!A=}u+H?@}=Zqz!WcOT~dSo`%kTK8?+lx2_;*cWC`W8{6lzfjr@e+oU9 zGttZ=`@Gp4vi5>>W5%8xD#W4=w;Q({yU;Jq6@6Yh){4}?AAkBoT*0@Ck)+8`eVnsl15WfZY`6YdNH ztx9*2FiYFIq2+^HN+(6aO>WLSC&s)EFK^GR8(ZE!+l19Ulm9|4VtsAxn?7W8GisP| zJInJjhTp4wJoit^Ui-VU>9jbV+H7jv!g_{6ym>S~uPvJ+E`uIw#k~7xXb9>RvSsw- zE}jfhlfBG1oI;brpXpSKfnvvhb^_0CGqc7~#d1;+#E^E#C`eOrm%mBqN^(GU_gyz@ z#}d1~%k@ihy0(l`{EpWFpBzc2Y`+|07#p}(Z|-3ITrSqn>tt=25(p2I?!4eg^~9nF zN#G#aYzo3^iG9}l4&!7J=s*1%Mr7?w6CoHF@R&=un6pK8+m~*kJO-RZax%HiNh^Uo zb07>sOb1R8s@rj!i>?E?%}&b0?5aO8&zUlyMWK=$^|HC~(db@f=Ae48yP4`)a3mus ziZE|7s)Ie1Q!s1zmuGIz*|6z$LwNW&(@eQ*B$v;+p)__&MtCAq%4G_bJ<;NLB@b&H zwFh)MiSBwjXUCvdISq^E3(5gAEU!y$M4g~FDOvXUmjdI?O!)xrcKOsmDvuFZ;?wYd z5tb!anM`i?MW#p_WYTm?t}p_5qO;G#^59z zgU4X3_1Zt{fAv>Zc#&Wujf3>m85s&@R0`caXZg$zoFswMdnz32iv{;-Jo@1LP@l~mhBQF-4 z$T!{wk&1k6zKpm{04u4+JPqun(0XsWxL+=gdUbeK3rj;F#kaO?UhSrN#yC(;M>oT%FpO%9PrkTO zp|3&@Y+KS8hRt)j)S;_fTIQEse9%Ge4{H%BSpQ zxLvr`aFj82zZnTtVTCBLo%1)RtQ!z(<_UZ*&&AMRxYfB zEqM6aM6Aj8FeE@jF3f z$qHH8wa;)6{-6Zg(D>-P>XqMO67|~BCzM_nXI5$>(42q$(KO&Crr<>lW^H*I`i=oM zLZ;TWW2)X=HZq~=T{OE#*AcP4avM2HnO+F9ieVD=&D9~H$`GF_Buo*)X)4ee3c6uC zM?n|0pVWl~S%{o@ADzeKIoFm%b$*ue6Wn>g)1dn4b5nGCd)AN|N+T;-Vr3MF`>QnC>cSVHCAq*F4>&pxMzvCRn`?9uY?qA^C9V=aemO# z`nIEhr%(<{)77gFs@FIvAZX@@BRZOMbuM_vm{MJW$??!W`1>z{QPEl zo1~3~e)&L=QxVXot4kcAhg^R!e!T3xbfU&J#+n#l6Hfumx3igro}4Q`+Cd5B9Oq9( zZV}2L;swhq*G%rd(#6sURK&xrSy?uod@GzH69IA^E5R^6pQapROz7a$4&xr{EuA7n z24Cup4_Z>eN(1XcgTFirQx@Nn!}_NDD-j-vCZalf9%t4+1RW=SOV(Sy+LD8N4n{Q8 z^6+JNMf-p{8yuELPluU&wB-A4g|ht_qgpr+q+Knay9VpoP-0Xb`g#kY-7RJyQ|Gv< z7fyA*e(Drfv^GeOB|4L=1c)LmfoVS$J1c-_k`{`n@KlE`NcI~36H98DDkW7z31s9? zBEeepLSnE>=M8IO!h|{M-`C|BTkTs+4I%kd>RZg5gzo54Ouq+ zVF7ca-929g_5!|KT2QQYOo{|u6O4ZZPH}C7>4YR0*f{en*l>aazUg#a+gtvh)|QrtjszKD;~8|2 zz4!^4?!fDo^%Xi4(1{rDY%*RDA%Pt+;8?Cy8PDXQ8Oz?Rl7W%If!9oSTgeG7W5rr7 zp0?}l@d1(sO5;coiK}Gw1CD4IxR9YzF`w_w(_C~&QiCB1GPwfYq>M>Al=sS&9582I zA1wfv-PV$z#9)|X@ba%=jCu>8vCZ6Eb!D!0YqfUuI}u}zm`o5Tg+}i94|s#n(IGDz zuqA|-$*g>9hOVAu`zWy>k39fH^Fu!K24cdnUcXlwjhU!>8UbUi30i#|FVoKUmKJA! zXi}h0_xgK-QyEsjKOa`DUT~*e`B<);qZqVVH&h}eMZ35D@LuiibRcFB3@bk=$y`KQ z{b*1+_9Q0T(!vz8$F&D~^~sE64)9JObJ$5}Su47OFV*;NN}olXh-l^**LIvHoLP=X zj4<|`#eb)$pxi#y2dgMExV7rb#dG(3?S`Tma=t$BG82=)o0ck?dge2~nv_feqgE`M zYBuF4DU_^zAX6S0EM`i0{f=po{_ei<@|cH2hx9U@A8<}w(;QhoIJJ#-K6n1xKgf6=Dj^<(fKT2EKykdY*b9) zvAMKd3Qsphj21Bmfz7E7C zkIa>1%YkaTBeHaz(b1nN(|`HJ^d4hW++M5ReoD!y=)m<|{^%wboDui*mp3co0CL&5 ze|CIV%kY~Z>Qq9nf?+MdusQTh-H!QyIzx6?E_G4ZjC03ZgICH;cv-$`)5(B0_A^-G zOp2*toB*0YWxpf5SMM%YAI=aqx@hR<^DX?LiJzT?f^_#{DTpT9rsV)Kg!ja1$dyEG zPpKT8DgzhqI)5JI_1`a2(uJfrE>7Uvjn7wMuf;HUMN@M+uI^iRuEX5PQoadQ)Bsw_`71(%{!~Z z`l>;hfFWHnh+a93J;0h+^1?EZ6&+qbox*&~m61|$e0U@lPYV^v&JXO!gjXu%vR=7w zXv7khk{6KL00fun5t>mOiW*0NL4Fi=h{%Xmme$=8{bDWr+!(I_Lg38|uk&D@-a zy>GfufWB7_Qb^$bA}oYjWgu#p4S8vXVui|Fu%ABwnh_4-SX+OQ!88O>ee@WX)CGZ= zhDp?e)HGct$tuvtEX+l&FjR@At+|jD`Pg7z4dTs%D~#jMl@EC3+;HI#W+*4y^Rm)} z$l~hKilzOsobVdh0NfP20w6`KdjCAexxX|E%T1iCtWx5TKR@#(xfvY-$bm1Nl^qaj zSd`Uu$!0-x1{&HyXpiwe$wG0VKQa49b(cbar;W7MT>)USFzKI6u5#yEEZvlj+pXPx ziZNe!XX2gn#x=CQuU=Zi5VAnuF^CZNe{K%n-(G8m|FNk%H(@;0wON^%Rgm zWxm(x?xXE=4s$um<3t7)mv1RWX81b^xwd@W^jO6D#&QL?O*AVeN}*05pYz7$C7Bh{ z8SH`mx}S?n8w>Mj)oCiJzBfvw9Wlr%^I2w9>&iBW|4IE0`NAK*LpDLnM8{ zl!j``LbGV-o_m|88o|Shc9iP3KjTcaPL7VKn{!4V`!eT&b>5`x!zL`b22Z~oj^Cd9 zK(u-Q^5rF$88AVX{I|LK5o8_Y4~7Ys3qrw8h?44f$A|&)N7@%HT+MrEgzK~$;@XO_ z?bgnWjpZkG-9zvb=!sJsn97a$>*2Lwvhl=}L4n;y;m=)vwXiUJGCZS%Sa$|Bne{m` z%Qf*uSq+Jy7E2FU{GEsXy=YpTtF zUfFCTye9BqX@@skl0?!S+tg0pxY7+YfTlF?Ea^Im8&?ry^hj!;TfDL>`M1Ig_nNBI zid=}oCix}bt~4Fyvbv&qFu$A@(X_4{)?A+O=w7;~;!I)wE)0@y1>NH}7{pSRIS0t} zEs8O8s7fmrxCTO9|HV;W&U9rrrGBmyS(SK|g|&^F z3xp+Wv(<0@1#v((xM_LE_x+zKU@vxrlc#f8U~sn9h*O1HWBIyJ@^#hD8?QgF_=7r= zaEp7a}g}=9p*6pp^ zT3XuMcDC#gEp4qkx3#_@T3+r@!$0E~TLrpO9?2CNm4jj@Pd0eZLlOG#zn9N{`tSew zjlX>3jYF9saimXttZaVq*&F`_{%?c-e+mEN@2|H8va`RxSANAB3-JGc^%eOG^ZdU7 zo~4in_t461!YgD7L*5&I@t1ELKKJX7|Ih#Z?0>w3LB9Uf`?&l6Xy(iZUM34i&da0& zo9_SC7T^AF-?n4t8{*85Gi3MkXTJa2Tf`_bo_kt%?rdpkZE4xQEw!Vy{oS_qovk|~ z+jokCJ^MR*yFTbS)}1<&sZ>g-ucs(XqWjEP$t#yBT+~lX zxvUZBAV|1ut*?DbvY0Esm1*Qzrp)2-N$hmxu@1@)mnIel)E1{}PZl_l!e#-ft&34` zsC11qVhK4$?vNvvz?i2-a`F#rC<%=96fmxV=+5RU9Q;4YW_q8^RoCWZ9xiFj@O~R+ zW*~78S7zmjsNJ2$WF1MBB!DJo6>iQfJVN1BSOqCTgL>WQzh z;j-(H9e}E_uR}Sxkx3}|(4h&?#{6pa>J#lb;Gg{J_cJJel^O8>GwjE@7HK%NLPlW? zCi+NAO9(HYKTsSWlr|A*k#v`j!D1qhbP=7YF=#{@SF`pM7Ya2eNm|)QxU*F8cDJ~S zschHrueYj-{w=m7qk4a?`su2OB4TlosB{Nq#Dt!qwC~A+#>)NZ+T^l zyXeHe@)r@YW0`y|%aM8fxQ1VadEYxumOpGq%i1&xd(bH?S8(!nj%LodzjK9yWH;t7 z74mcpI32Hq@Or&rpkovcj1*aifTCrF-8`ie+)0YqY!eo~eaV+LNZ5}H<%Gxb?zQ8}SRFZ1Z@RQq88%RO3x$tFVwB4kS6aCI4#q*5}e z71y}(Bh(f*XDyttZ6j|~}iTa=m+rM*VGE9`;4?}n9r$n4_&j}LfpCOKJ5S*QSaX2>w6VZA2 z00BPI8N5jVl`#zTD!PtYS zw7h3;lG?%y>XzBy>~yhosyyO(XeElZQX~gY{i0kf3{;8(^eV<-A*JJ_sRxAZlv@p- z3>3W6=*#JU4EHfWU z=GrzdeK*C)Mt_ipAKD;Ycpze3#j#0IDGE4I$H>T)QAwwIBa7{ND?l)C=`Matj^*E@ zLpgCX5+lC9iO1RW*N73Uz2M%w7k+TYlt=6#uQ1LXxwXQFyE@glpthngCvAeeN?qdb zwF}?YmKM=+OdY~W5yp9-TUs<$yR)?ZaaVq%4M^I=zf;8V_e#QAP@{3(Kfi z`TS<>&ULATI49K&oh3L=1S+Oc#E-|h%!LN;fVpLL3?)pv#A=+rk=)73PxuwKfM>LP z1w70-A8(0|iJx?HsKKF9y|ZAaUc++4W*QL8KhUeLN-6)7F6;~=1U@@}_BWpFHK{Ur zCZ(bVbB!yzyKf%gMnVN58IichBJdyUXoI7S^JoF5XJ8IV|FtxORDsWpk8;_{NLg76 zkzcSf7@~}feQ=JzG0uLL#*%_^^h)+|W|2-zVtcSJmQCRNGX45WVNqxm-IpV!tXF#W zuC$w2s0*J+&N{~C?MLB=CNHcZFN{UE!4gJE>u+U44InsODjS**%mLrtA1`~Qp6paw zQa9?D@{gikSw~gBh9Z4p%nAfzsWXqY*PQS)JL+gVPI1r6KoIv&+Q%I}kp-if@(>+) zSr{=LdO(YRL&y8+ zMsXB)kX+f@-Mg2HWW9W5QV(GdIt@jnuO2$;P5DgN<-NFvIGZ~B&a+2h-#k5!C5PeQ zosMw8q)!_)9U;T)ICuHcyPh14+bov7l)tjD0~!2J~{aJ#b{n2hw6qOFq7htYP68z(aw<&{mdb3nOf_p4u<_-7xt14B^F_pJw; zRhIU;RPP{v508S4`&NpcoKAZzr$9Lj0qHisown)HUp(D|xzTJg2s=6KnY5U6?vAD0 zaIPRTG1JZm>L)Mbdt>;o8J6soOxkR^MGu^QG5o!;Xw=kpB`8$?sPo8yU{fahJDvo4 z_r@ggip3552`v$!Bn;fbG2g>2wIO@&YsSPM@hz4kAfQ%1u%Fazvd^pR4sXiAK{`9j z77p?Q2dwqNkBh_k;vg_R(y$ zHiJYhby*LbR>GO5y|J@XrtcOTWdWn#A;{{VH$R7DfiMpn^+|D1Zl@fcj2G2@(x_t` znui2b?zELn=n}O+=79szMmbs%c;H}MaR|z0HQjmN{C4S1km>|zjtGzuW|-%zOAq`5 zAikEnPG2`ACYtZnm4fT)GQ?0He`~7ix5m`#&i>U$Gs8^dy3B89m$X`xP5LA=O_M*I z!fh?4bOpJ`E@V0RMAAQ<8ZCa$x=>L*>xF_IcbwT9U3S$)loTYjrE3(aimbHJY!pqG zO*%)uCSwWq*FmAw^ctML9hSorqG?JiHZWuXoo33b#Njl@_ zE$0K80P_1bV%KbU4$|+ueOAt^FK(y-JoSzxL*b#|`jG<9z}YOHp=s1obrj&N%~(?PvvsjT1ytUBusC$e7uC1b z4I~^0>5D$K@hK)6DB@aKK{d<8Pa|&fCe7K@X+AnQoE$D?Flf2RW=f|z)bICNTAJHi zqS%HzO96xTR?P9P{IFS`Nm^DHPtb+K<{I%gPzGSGlv@Bv$eb{27_VvLlNFTxpf-(K z(XkdxOJQzg(x3Vtmu0Sd>sEzT%!Ml@xO5JqcEBC8cv5`Of9N39mvJ`I%0H22(k~<;n8wj{{n1l$Q7H9f)0nm% zZHnjbOJ_pXE~#`u?37J&?enxmn9|GAEEIVJ)$^caKT8qAn1eUwEDuBJ@kN&5@jAW) zq!oA;n*f!k88yF7ZsunN1hl<&Z0YU<2n#`*Y zp0#&ezp`x9{BkUh8Z7@Bi>0 zcmMwwr~hXAfBVk1_U-=jzwPz;|7t#XCmJi|iX~EX55b=tT=62fS94x@pi=A*aOaLt zf{B5XXhi7&YT(1unaOg*E1WErhGaxWOqqhVc9Zg^<&U8gqaX{(enzjj(k4nlon>v_ z)by{vxpj}k47XNl(@O#_+_m``85oQ1{G$3`4eoe&i*)of@8o-G6uDlVi z!AfEn(ylQfuYGxqf_}k$ScVvSGF|%y3eKSUuj?*^e6)VRDZiBK{^0f*uenEmSp|mMeJ`=Q|-w@^`9=` z^&f)^F^-^F(fkj!rEBn7dlpzh*euiH>cb^zU2TONyETU~BBbnw4f$JzJuz0|L=rMSOD-oib1&ufrAZLy|aK40t_+&3qlb^Ii@g1 zZWE&G$l<>J-p-!G{o>@Q0ohA|c~p9SdKfz|T=XUz+TQN>yL-D2cXjvaag7#+Lkb=_ z4E;LT4HMSY+1J&1pc|ijc=SN0d4iEIBiL0EYLf7Oahd`X-d?MI_8p~$QQei3-*fmt z_s1~RX9m;)II{~)I1A(Ui(9KcdW@MPFet{=8Bz*3^HW>C4kdKP2-)t3hkO3^!?5PZ z3pt?3kwC)*iiz(wkISM-2WP!tiJZhaxwn1*nroT(q}micwQ~76qqaiP8#;;sRBk_| z*`(SXW)qBJv@Q({$%DH;RK^l5xL4H5*;70cDUBuaMCN947_gObPfxGdl~$|2J5NNC zdSp`vvxg-WvRiVGlVD#NM>Mz&>@M#ol7$ZB;EYUDjPjralVeS*Xl_K@uRNiHMGo)s zZ0+)AaRm>~+40Tc=|xtSs#2QBE<74od9qybMh7@K#TEhT14AWoRKPOh*<58HU)(C7 z^%V*+3Cpqm`!xlnAt9{~7uV0y>KTDON3)@m**%rz8aZ+KT(krl0MoTEtWk~cuyQ^u zS3z#b;Pd918qgNTfT4{Hn$5=jy^|*gKCx5vR8Bpt>@LI~NFN_2Il<)yc8hPmUKTjtmA9N2L>Gw+EIM z4I+VxmMfU~nG7sBGJD*L)@}uxq}1ZSOyS;sGi5y|t3JS++BamhZelc|&7<7T#6#i{ znfEFJ+AKL{p7?MqQ_5I&oHB;B)BK)2BH9#=F~z{-v2|E1r+>7>c@m)K9O*|N0JGsD=^pWR>vPS6wSr+&V$p2> zB(V9ZS%1!WPI1b0CINI7RCxr(ip6}21{qfz=?*Qxl2TC>$}w^6(Q}O;C<{+ z4?kK&RYDLO6g@zXKnur?x131jvI&_JE$JzJEadqwg5Wv|{0V&gL*V%&#! zaXN0cj!-B3l%?IKEG*iJ@okwX3m=3);87OyO3BIiSoTLCC)VQTvU)Z7nB4VjMCH{m za)H%pz`;o=M;JrX*WJ$ujBJcMusuY+c)E#|*~0=IhZVv^OhDq~GndAN@H~O(O8|ru z&&1QY%-p|$v5U^b2LdeNwilX_gzz(m&&%8)h8faKma~LqExxojNZvU+XKY)`wzwqq zauAx}lbv8fJ{{#41|7m2HY9af`G0kS*Bh7DzoRUp>)+49oml06RdSptgRn5lg@o&fUE}NEb zv#A9;&Bj<3`X{F4S#s%pwY2E)LNeAy6&VsgDbR?Gz(2y4+q4hf_H+ua( zhLFS7P;%HN0`xGK4JU}Nsb7Hl(F(l)^|R@mlJm8v{EHO*`=+QMBjno))!(hDz&ou0 z3@70XaO{?$b5j#0>b^B6Etg%mO;HL524yw_1UFWkLu%jB1fnq5q0AKTXf?bk_~YEt zk^f$Q^q4J;NulxR@y6xHR_~KO(zOY6|0r-1@ekQRaC&$q+ngXM_bJSX*71gldq~Hr zl-x*Y&zXCLOeaE7#;tCi)3s zib8hW`HEB^ByeK+40XbvL5agwwTizv(sNjrw!~KWl!Coz<;IO`$UoKB5wP9=i?Jv{ zo=i#wiCSk;O$q)sm?DNOQx-#TNf<7cCI^U@XDN$*NeVyw0nbBD&n`LJiueW0}YJk6w6t}~UWJF`2a)sI*-`ehlgF z^*b}%?eB%XS$XZ;StsJ&1b~$p&7VaOyIl_>l4O} zwyGTgweU(GTIOXUqi^03DP4n!nd$27A70&Tg-uigyxYh3~ z;r@i^`|uEY^U-C~Dd9&z6DSaH@JGTt>Qk&Kj=Pw=i$(bMjnvony+q7#cnV^^S8t;= z5FFwP0f_^<{ZV%G;cRV_gyJ$h7sL zIns?Kk-2Z!SvoO_fQ59fNRL%eFGgrF;Ii#SYN9A1J}RLN$@TUZvy(FTE+8ZeYH%7% z|2p)d5lTwoZNxvj9X?d!5*iyiTx=p`W`Yv5YZ(#=7PS-!`rMVzVKDEpV!46@D=j@= zNA*7A2jylv!d-KgT-PJQ9-*B-{VmZglYi;VT3eYvCqfz@yGwz zk)o6C$ut@aVi19~$2xgE$E|Hup)O20$+f6*z^nLPku~Q0x3!C(*n&6`Sv~w`QGGa5 zySs)?YfYJ?<-{k)S2jRKynVZYnC470g`k zpX|woPd1uMaLArNx!?d+3|=m7O${ zLtt}XJ(n_ZKunihVX(_rWLK*T!Ud52EOEWS> zX&|x`M)@!m^OwSmlnrxUexd>k}nw4%1f?gFMUHaw^;K(p3A(p2x6tkinSHNeV86tv*$k zJBOVI`@4HXov1nw9Du9#!4D4|uInPjeEMadA8% zsrXPXToDo15(BtG z0JkNtV$2xedrF!+$dPCX0KW1InBCDUFRu0!^)sM{k8GxM4;w9m+?LW?(ZTvgNJMYK z&dBV?WXQ#n|hkn?O}w`+;+Y5!H(2-!P}bq-4`Z2)e9KgskX3ei@i1Zh5MIOk70;=U6FipFF#&?^wfVBw)}#W zj#hM&hI(@#AP(zBcxUwz0oK8~eB3hEzU3 z>nQezY5+MD9;=!|+6t&uomK`f^D_dC*M180i=-9EB<-^s-=j6|5hYZ1 z`ev@tioKI8CwlxZt<`RxR}t)t8jco}zK86f zv&x1mM3?=qR>EE7wP)&!y2;B~Kt+Y^8SfO}71Qsx`(D%14um?_^Sb(h530XkQ9h_FFqe6AT7BNF0p3F1aYEcv>1tC? zC?)SH(1n1Fo8t!@=8+Y0XHpR*?5mTk%6wC^WT+$r*ffq6uOA%W_Z)DR%?(zERS2(J zy}Mj{@@*&xpIWww7%wLi4tkcBSN1xEZ4MX^5TAsQQs-f9Q3e7!pn=sW}8> zMr!j{unaED|O8|0)Y!Ll%tJR<_KO-ypO2cv239C|f7s186 zb|-w?SSj>IC)In;DjNwGw8;EUuAHW}vW6$r3&~UrQKaOuXap!@-_+oS1H0=c2BTPA zf4HJ_cW+T-YkVhKw``XZVkZ!0^zf%NU)jt`<>eSRygCb|EnD5l#cbn!E86?U`untuT{UEm8(t`IJkx?>jS6Mq*T^*4o-4jz+m!T zA)72f%gyD|Q1g(suaVKP>zLl8MQqqgnG~m%0^0x0+c3KSM&L05H z0pKK%rT9Ve6Y1<=^Js3kMCp#o%`Mxu4U}_4T_!LsVzH2ddm~0R=NlSQi2<4N&F>~> z^e6$K+wltKgapHst@>o38(Uem6j;PYN)9Wz0A<4K(9t&TTtPOgdiR<(J6O_L>B*Bs z&QXBvl9>to++{e64m6dLy}LXOa4mS(p~M54MIu9M%^0b=G84mdF+S2j$-T?D-FL2T zgi&>`ZXtIzdmLd`wt%0L|H{^G)eO6_A%Lyl*eELkUf7y#j;)!`d6gdFdZU|^42wl6 zOx(R!XVpXR6g2GMl|q51>DuICA+KW8=|-0diQh8tr_XbQWcVZl;PjD*+$MUPKisDq z4Rl&Z{ZzrPoN%8ZPH|yQV-#*;9tJeKxqF=MnQF-dHgh&vh%?5UM&E*ZHHnv`laRar zUw^awg+E5A4%X$rMj4GrSm;tdGkp!&%=n=p8P3z93EcWQ0p3!IAT{nd`=)_Wre zj%l{}{frQhu4wgZZLT*bDB*2CBLyQ>6MItoAw8Q8*{U9-h16eraf7q|46-dbmfK{T ztfI-)r?Ywbw!Gu8!Jp}E< zLYh+5-8$|ONI7-bVMw(d{6lHonsE*P%JY;pa#y0@)CF;iQvqG{} zIx24J}sie>CDs?mZKbIORkejc}Cle1By$jlpeXvXhJ7l)F*nwOoUmS*0{tv5gNXv`>O1K8w`J>LRZcJBC@om@a zxX`Rg!PLs-4!9sTH(}Da2$(|~7lSq#xTg36d+B-O2sT2z*I!1EY ztXB{x^WGWyCplEii;?8 z4X;Mve@ZYqm>>6&J6c+Rt@{Zw_SJv7A4~I_OxY7BVSRYfCIZ#IKl)g6H#0 znP+p<%jU*M5j17nZ05AoB0Z9PkC&u83J8IB>aU-O^_A)MhxbLgJeC>qU}){>$h$NJ z(@%gI#U|EQ=Hc!l_<_JKRXm{N+gtSD@MjwkCQ2adWs_&}0AEHDv8tUO$yGd!GxW!< z^7=^r5BBlre+0r)%p(ghI6gd_D-6GkBv8+u|FN~T{q_0(YCf;?KmJ*N>g9iYnRMVe z@;~m_zT+}Cr zd;<4>X*u!LGG$m?TEt5}T*B6$Tp}Mewl6fv8bwmP54Wbl;u#^ee{jY8@}ko5DqyU~ zxL*DIrfq5pxAys26i8ys25HH7ce;A@-uk1bswHXpBr{4rWA7r7UN19LY5xD)d)M}+ zu4_^FyMD!rk`~F4Wck7+N}va9C-vUkjtx!vRofFJGJUeFfGC6=?VI z1|jve+jwZjrl``WBJWl0e1tP>H?Lf#n+gNbyjbHAY{Fdg$_MetYx%;ti09Sn7mEJD zi|mQ07B-42E4&Et&%r{~mvL0=(j^{F3S^3c)bk_p8(Tc!x4z1GuU0M=@?O=?M-v|9 zbX)wjT)97QqI=?{$RYHd?C$c|^5(@b0Pof;4$|xDz61}&P@Tc@$VMlth0?njFD+dn z)rj`>SI#R($&fLjy{W~ZXjqV2fSA~pcNnPXf=l*jI2sy131`?68o(PU zPgTk#zf?y@(oqt5z3xK+XvQfaRw{>MVV`u>W)9ze>fU6M{bXV6dh%lCZ71tqGzb9l zSC2xAS9UeS;3Vh124oAJr1Gv8*w%*z=M`iQx2}7QkLI?%xsT|9Z=J6Dd2bYHh6-&q zPiCN>byTIG!6ZewA7%~rJM01&&Qb%A5#augj|>S5gUk)o)c}K7TWKtC1z5VILS(zC z`jH@TLaZot)RzfT;t1vDn3S?rM=xebG0UPBZ(Jtu7eb_& z*i2lD9L^YA^Yy}cKi_@$3=mk@4C_pr$9DTamXR-`@9^4u-hxT6hsfTz+;c)K!X%SBpOQo$%HemF*ytju1_sF z4(RPy>54{Qa@iyC;E{NV!3#}pC=S8R>%e%B+{O63wI4v(c@!_X81rl)6ZtYW#2g>zl|^dA_~$onX*v#NQMIPAKXJ`NpEh2u+|l^g|m9; zG{R!6b2#?R&_7W_esm>4Ps{mHvA-aSpo}8jXjV7Ln{JcH=apI1)v%uS`VuCbkuqWP z_Y2L<&q+rlWwf!r*n-Hbk-goQPFK#qBhk6)$s4)oArp)v&B6LDed0C$KNOwNA2<9Z zl!O-p(Io#=9UO2#sBze=2f;Qq=C1D`6oyICo;Op-*C$ch+nY9|SyxUsmNl^bLIpBJ z&z8dcB|b>Te8n0T^%^Q+^0kxIa>cLK3x4fnuH+Z_#O>yogt=v86IY``?^Z~_-0 z`&&mWs2AY*a#qVaWPbx4OyZ64Q}95Vc%yh9@@6Fyal>#m&1N&5>1gH)zg=p+zkuc( zq@>OnVBO7Vt@L;y@U_6#AZwGJ2xt8I3`mU@ljD1O@r^RFlJZ`n(m63y9lhFLRR&b&?i_jS<>bq>Y+}ow28@OM+#;Xhyt!2o?5Hfr%@6K3KbvJIv{2n1L)br;D!wWq zCDL}gF>ApK9-Ut#V9ygq2}4H_03+I|KE#%s5n7Vg=W{|^rl<(ZEF(i%=$Hy)f{Pnj z`L^3Ph(S893RjtAAAPp`IPr^P+SQN zl;C9)!Am$&fWb`2Y9awha?0ZB&20AXGw`V_Q`%J>6%}i zmUw1y>36ckwlS`AgMjHlxV)JGCc#J_ZK?xH6u?j(uK_{br8fzs}8Dlq%^u3om2(b7B7tOq%0G= zwGOJPny)G-cGTkSeyG6qaScaBu3zm)%3{^-(6kX6{fWX_DOoX>v$Q6i) z&vP|4P?P33^U(kO-GAH-^&Jz^8!e!6QxhsvZ;G9R&=6`XZGa3d)wVwBb<^-}A^gBs zg=lWD#3&fS&}@qYG~ZtOs_~`L-}VyNI~e3AViIXJp&dDT>V)kio_w>?{QXr^B8eEX zh$U#mBY$07{OjUuAk-B379OO(F0SBX=L&o!3-eSv zi3z3Yjl~M14#mQPFma+$d3{;Y4a`Eq{vc*W(4_ZvKxlm@_S#f_JfpJa1xpD0-hK=#dM8Kqc18K)bROI&J z0u}IE$E0!1wLi&l%&V(Lnqr;6PQRC;WU$T#C0D&YD>N%ziCbibqI9`ZDSwe;FoiGD zPfr~?>fRuE04 zUfyWjTBhnSbGI8`UZv+RwsP%=VVdD@XFY%xS17hKc17A?m-a>EM9^gSl#YlJUN$3l zqf)LF&;(N`O$ZM=JS%J0O;132Y1XtdZhx|ZX_Y@&#AZAOcrK`i^xz0H3JND1!^f9!(LcsaBJ02mL~SS>lgv?B|FU;-~92vlZWe{HyeL^j22?C?pTfh2z%o`qYo9L;_*wJNiVMWB6?j! zt2!@kwLFvX==3>jtdN@*m6}{K$GQWmoBJuD4fw_4j=P~yyv=qnc1P`{Q`qa=)hJx9 z2}FiT)l!qWT8!s?$!QI&`e|n}uty2KZ)`6wVFeBNOI-YZdAG3+ z${0p3Jfa*yjX!H;3Vji9?ZO&yXogGt-JGs)_GDD%PW0b_B=e{CdX;3K^v==;2rtgV zP7r}Hj*f%(PaJYUdm2$|d9Ijq%Osk_rWA&;X zMC4m@=6hXfz+_U3MUGvquyn~C0{7OX)ZbBQ)oV#Ky0zK8{0 zR7BFo*7|R_P$O&?@e=KO14;(DYi7Ow)u5yANn(HTlgjLf=k}nYI(|w{*QXDRI9^L>bBrTfwFP&)Jm=j66c72);owW z;E&hP<~vw8H&moDpNC>4SczWc}lA2mnP~|Fm=MObGU_o zobg!vR71ea7yJ_&`*7%|a&>2Fo)gP;`94Aolz%ob_UU3M5$X;K74lbci%A?^nO_ zj32#dT-EetTL8Rb05<*ISo~G?^o`gq(}KdTSt(RRe*<6$SRQI zZl^PctTPPe=7tT`v7km@^+ccxlMf};NbUB@2S~P(?f^OBG!!^H!ZTcqxPzTx&}!$D z99VItV}|Wj0O#ei;}sC21?+f zm+v46I-_v6#@YwrZcZP|HlZlKki4Z!xWl!hKq^Szi>*=5l{`y_2IZ*G6GEYFz$ z`$J!KKmUY$T>npUA5J18PZFd3f$D#x`+J`Ie>{`VbN|nO>Q5{G&!M$`+xq!f9&)Bx8VLeo&UYP{rmR2^*{FYKllH9CZ9n44_)A0 z+l+*xrgrHv%YSgWN6^khI2-x|Adu@TVf8_c!k~emHCZe)0e={JpZ!o)0@>X-L!$9Q z0EoI9V)UnnyHCHxncK;8B?z57YYfL#aRCtmSrpSSMu53q7T3|-LE*>uoRT0`dgfqm z3o?t1D>n{^Nfs3xt23tfhJXY;L+(_s&k2xX9*VT+)+p%wufysrvQlYDrvp zh9;+=OmQMBkB&|a=EVpDAg^8bd6nco=T6iZy`3VX9U#+J=8TaGgZfPNm|e)npznN< zeLl$Fqh5s}V{xN8+7F|L^bol6>1*ASJr=}D0AOv3ofoDs>^OP0T=nG=7{JIE^tJAa z=|`gzbjSs0+*_mrf-a&~+&pzS2FJrp))NT>gkVIH_j=hoRj$aYsWGL)cW+-=X>2Y6 zZcxgUPw&Oe`)BTas*GLTJRk8_BLV+XPH1HnRZyAMWRA2B5Qcom)Vx(%F$-O?UaYsj za-NbY<2}yJ`;GOl6CVA>Wl_-rA#jHs{F-xX8N(KW7=m{=(xK3k4qRqgS_EP(Va98H zn8|mM%kGN$D+A?Xx$2ElHCrzx%^s5GiK`+^*Y`6t8=nDID_A<0zS*ySK0YT38ObJC0L# zbVi-ko+{zJY1F#AI|=lHeYk;k)fW9L)xuP+dO<^wKV%etkY@d1EWm9>7p?4StoJsQd#J{Q4?8q&c_uyrjF`+oc@$I3Pc%}T+uj5dpXU8l9@!M>KKt{c~9P^=mfV9kORd6bd?wA zjYQwv>&{^GTQGX|2^d_SyF(bg7K{TD#KK)5+JR0LbJ*74rQtl7bF5+x(;6%=aSmGG zNyq8UTkBs1Kv2do8;Amd{I`3eTF3|AOeM|(-44);S&HByybZq6%fQ~H(gxA{t4F9bzkYn2SK%y|c=NkdRG$+yWF z#&q^+<=bQn$D@9(IzDN(lc{gnS+|d_ntHl>y-AqC4E%o*sBNhRuy9N8K{$PJyk@DW zy;mv1PEG=G^s5J>)-+S8RMbw-){}%rvX54&PvvsEw6Y-cCyguBKfgA*5@m$d#t)tWsvpkCt_#yj+TVHPk z9bWbBJjq_XcCt8KvwITOTFBG*?bnUZSKIBBn^w)nr{?lExP81~7i*K{nZx;WXu!;S zKmKtGo!O>g?OODh)9Fag2pb*fto;U@-3SH&J=NYFmt9n;YF{CbgsztC(X>wg*ozcZ ze>L^cdC30OY|mt0 zo6_p{|KV}-(?y3L?tTnAbA}H1g25HSM*8_Wuqzb1r72BdY4h5OHl3+3qyglI-XSlo zIYP*U4CVaEL=V>Iy_o&pOL2vbCT|OiB-z=Hhvp_r@Yxf`sy!s81qP&E3z8!g5|hc8M~Mnd3o9He#v&j* zZp{cQPa?VeZW#u^@-up}O^+Ayev+JwWqUfTa_H;|M#ZnuYb%D+mJKatV!51UMIKBT z(Qj88pMQnEYoBh2NNF_N%EB1}w}g&n+g=393cRbK>4l9jrYe4tjZ4y|6oRaDXAt#j zgF~`udy}%e7E`jg7fk>Cd;+DKJ;?yrPVw* zmI>@2M^b>1JHt2t(>=gzF+A>yxq2<^Q4DHI5w<*;Nsi=~ z7F4l960PC!D^!T?2Bn@APy6<%rw8z!TU_qyS5ZeLq(Y%a(pYb7cOqqDGPdt_$r<$uI)-MwkGfI#SVrV#--U#Q`wNxtPP z*oh%oD%X>_VzE2}O?S9az}M}}4Ob!9wG`>-UA7bWY{avX*lTA4w4Itkkf*;{vE^E!vdt`es}Lp zdz0w6oiToK!^=Zs@Fp<{Lkpdo5>`JBjo=T9qYjOu({DBd_;;QeopVaqgBn)3TI+Cr zZiqh2Z=X8ThZJhG2o!HKDRcNLkcGe4`bOyWvyS#g#A^I$6XdYgh{g7X7B&XA_%i94 zI+oi_gCMiw^M?bvp;@YsH$&%565SG=X*>$0JJ})i1~>6Sh~GnS8K%y_3k}mCIxW@R zA_6WJv@7joGz+-{JCm#V?h)X%DnX-5uXRuM1;AJ~r>T0f$J@a)sTSYzkudPc`e}5g zP)7zSGeA)197#c!+>{Nrmxa*i)|MI%H+ER&fP)FJ%(~-X(!v7b7Ie@zeahO#&xio~@>?xbB5@`tL@hr9{p zj{_ioB(%2y5SA=_wN5GuB352I?v9=`l>Q#t2dGoFl}v+@ITLJaTuzC@4(Vi8d+M;^ zQNAfiZfFwU&f3jdkrMJF;^WPF{4P3dt^dYoZJZ+-0jV6ce&HCVYL*-==kjztUI%%+ z25LFU$f)Q8=Mw_l=(~Q=M@{bZu&_;ASK|r;U>BN?=NoJD+wWsQwIP@Vm53*myG)sD zhs+qdiIsQgaJ8Ddkiwwr*hP^IOBc|fZc~F-g?5pvQ4uE?6#M2JBusf zXtCEqi(p1JjTo19l5;vZe>13e?0Rcj!Fr-?wtp~lu7%tr2CfpRxXaK~rG7!v_eb+K zaToR_qg6})7E$_cU%$RJ3oQ*MzIH&{TUR6ue}loptV_9Fxu02SJuo|vtXY#1@_cxK zubv)Q%W4(fWyM;udt&Hvibv@Fcx?r*8d@fZ3@#F%2*X&~&4nDzU(nPg&dbhz7~z;7 zzGfl56sFl$-I(EJ>oO&eqFhSbAM4UT0xN}T^x7FycrKSHGsD4)<3&GLrR9_;?GDCx zZA(4XZP8n=$3}-jI#X)Y5Ob)@R&-mR1irFxAF%OJdAJ*YG} zx~WiiE$ACUkWqNeJh>v;?@Cz&<&d!o2 z3bKcYgl6Q&4h55+(3OBJ)|h?bKE}vAC0`G125+u^7XTXW#b!ys(yWOYV?SALCG%=| zmKa@Y8zyBZabOt2lV%f^$x6y>*qdE0Lp?;rf^c9vGlPuYn;BUJi^(TY=?yPX0cTq%0BHy0)yE6+y)-&O(*-(KZ>{T z_n?c`AL#d52T8jo^P>9d^v>^33cGpz>G*GBFX7YRfs+Vb9(2EZJw zF;i9Xc*^#j>&(ZCYYy68U9D|wyKwFb_ocM{bV`f@bpi~r@h!GLojlMMMGhc}REo)T z3(N?sm+Xb1OD7IZa&WdclRDC^Bdj{DhfE7$_HVOpCVOh{YJr{rOawS^Nk=VJRsOSB zb8Zupb*a!M<(xqD2)fXL7GYA#!N5El?4}Ma@t{IO<>BR-UF;3keP9cr54=Pa(-0p1 z+n=EMM0I{N*Ic=q@p{vl18JFqs4tz_pO!8q4<9!^q9{r=5!;C*I8m<$t9{;Chzk(r zoG7WgO##<0W6{u?*QEo6IIqG*g@|r{${PR{Sf26*tK|xuO%N0KzHa*}z%v{!B+XS1 z*{P#j^>YZWOul6LY*1HJ&Scx&4&6iI}=L&fJ}@}*D}JeoGg_~zW7Pp#u{JnKjwGwx?XknoC&#O zR?o^99aG2nJ6jlb4fPjaAhW#p6R(BY-^+N>sF9odBDeq5ue>NbpsD0lQLV$8CjQrI z=O$Ed%*IUa==gF9hHvLD$JFg2XS*-y(x_@1^HcR) z6%)%57$<|ffc7R2hvN4FeZ2l6KJ>I_2#t)h8xP!O?Y<+J=MBI^hEjkXQxED#WsimvCque;Q zGA#)Fi%HI$cRQTsqVw3pJtlwbm^lNHe$r_tc+}Zku@-D`$)2@)pC=cH>>6&`e8+ZZ zC(I8QouAOv76d0zE`^Gz(D?^ht4*yOCH%>Zkr`DSr#OLKzq|^98Ra!)wesT)RrN+l zhTp6pROL}?k(ew68^S;-iQ%nf419Mh{s{srpJIKgzka&*e>Oi7eaOMq z5$TnDO$j0`u??sFm|WFYmvINpr)ph*^e-*I`!=>7tT)#0H6AR{rW&>zbGVpSL=Hgs z7>5Xhrp`n`n9i7GPXf;(cEFKXCygg5c!x+7q{;j$+uQgf-1g?x#m2Q&qdJ%}UhGXd z=_bU*zHQeEq?{0vX)u+unSMg$aW*r+z@Jm)Qn%3#GoG69#9Hu=IN2=pN&N6p^UjiV z>@!S7qFiBf{_fTz&_lAyHbl6m5t`13@PuQ$d2f09%53AkyL?%VYxukSw16}$GGVQ5 zb_?5%a{>^wQp7~jE18rNj{GBP@Y!|Bx~bCW#Ioj{k?t$f%XA6EYpDg5~Ee#lkZ?OzD?OG`k78p!nB_Q9!0pJIuaAk&r4eod1}= zVFPOjbWI_GOrsGo1D!8{2{k$)8xElH2&opya|PI=q93A?s7I!1V*iN$u@2z??gHTL z3Zid}gYb<#mrmTOIN_}u@Km&qM0!%fz$h}dpd$$ya`9g>tY#yN-mP=&%TVT>wH1D})QY(yszK$AkUAD>Jmd+C*E8qG%bn&~AB4;1O$BAqh! ziDWCajFmGZA$m}n=4@nt$~!{hsxqn2Sd{>sjT}gMLub!|PG8e3lwYTHxoqT>l(?nJ zNT(Sd5UPpzmr?+;5xgVR*AN+sNug=x=^r2}V&nFuq{%~`Lsk4_Z64Vwb0$$#uyN-) z$m&;UyUbKElnjQmpv1B|{fkDzf``y}-^fl7!jRsaz7;8>qnXedw|amPq_e1C&Z zvvsu~*qNGr9I>gZ3rRjm8+YUmc6Gs_q`!a`&>i2GSMlNuC<2?(P&jz-m98F1iTW`j z)x-mp6X=yxJ1bg@1v_{qfv>DlH^fU@XKf=C#f0?3w~>Oqz=8x4Z8xvXQ)P{f&^&CC z((I6Yo2ZnN5k#iscC-1kO_wCPIEkZ#jgB9>dB*H~*_0znf-&TWtd*JENc!}modQFS zIv6O&Wf1wPj*ycAG)dvXKsag~624|&uPDdkAhF8Etr6I~UJ{AGdEuSgHZ_G7MW$j| z=iV)=UKS|j<;qaB0U|by3!zXpATlId$;m}%342ipPRp6xg>We(NDHKlbF6(OlN^cLK|4?*;x3T9>Qw@rXkgq;>xdD- zKag>#HEeN1%1^^mzzI9HF2cc0N#28LvSf$pqgXo27VxOC^e4v7*5s@ff#jkHhwP9i z0bz(~kt{6$n5{|+U|rC;uXSd#jGfw0T!{l%MMwp~(FgjS*lSby@rO^(hJ7DDGw8kDx`yb_m1guJ>ZfFHPGU5q1s!vdZ0nstjK0z07O) zvJ7_(48f$+J1y!l7{sw$2}XfcdDPTHZ-QVfAtU_9NmuOV|ECHYNxUXX*8Z*dEb{;A z{QvuV_wDWLO=JGwy?dYM|9v)}=lTEtsXwjq|36JS&}sgEeg1p<(ml`f|38b5ng74% zmEQiI{rgh;5A^oDvj3I7=lTEt@jv$Y|66eX9rOS9?}djB|L=Rw|DVmrFa&sL&AN3} zO4OtO^QbiB7-{rdv=(vsli)yM{6s*kh*ol3SeeAZi{xt?5W{n zL!+k-A3K>UHeUtAlZ&99EB58MEW%@{wcWh0`x1D8N>k!f)7s9EE{FccW>ctE!z10zF+PYrpe z4j(x>aoqvpCtJ><4POr|QTZ>c;Ji&IN;Pg=J; zgH3tEoRP_P$4?(U%C9!I({7uHh=ovwmFC(K6~GV+Au4W~m`%&U&wE^G>Pc_QCw=yl zh*`^4n19IBu)Z4D`xaX}PVSt3b*xZ2Tjss}@3&n~ZMXDvF$+V;GOOod0N{CL5bmnik zR|)e;!ZQl)w4=)L$j=TRJv}t)#rBvqY)``5V~mUL1MJ`UnjC&(1mu6>krT(gffL7H zKRP^c${bugGI+u}eR2@b)#%Wvh-VJgJLnC)HE{Iw;Lu>ooCCBnW?{B6CkL&}2|+U& zidDn9*9$TN^vfnHB$@6Sdz%{$RrK6oG&mE3&FHWCNa1(#IHq@r``bz}cVUdLh}D)R zlMYu9+%0Cc&v?nb-v9EFJ@F76uxF#zl##(0qs?(KNL!8%(WDONa;)ap>xI%p4Og>J z8^h}42rJ=PqiKgok+{d01Q3F_RLJ9hyYN3q1M%OBm+-$ohyNt^I#`qlg<-y1L6ct$ zX#E{QGsoTzT3<)dnD(}tV{d13G&ZUow4M&nT^l?I9!ZTC=2i5T)gz zjIFr!)eF%~wE1WQ`hxI;y4ZJ%z!;^pati*RB`xa6=fZOA1eLl)8TJ6%Qr+eu_=$9LN8kdbu! zPR&l(3IU52TvX##Mk8c2ov)|EZqPaUJ#n~$FXX4fFUV7^kQs%%u_F=?JoF|;ni<|6${?3a$I`IR?#1mMkL zZg-*K{R*?9S?Mp8*JY#%9F`wC|Qk7mYuf#xD5EGjl!!i7_)v zotpFiNu)r6X$dGs(YM?+E&#E4mpj=O<4{0@(EMa!{9Ql*XGEmnicVV(zd`f0U_e~B zAOK3_sgH?fymfI2&M(yq^$TKxHy5|Bt;7GJ_ZAkc3mKS%W*8x;ZJfbz!2$t}2P9(U zLZ>O9g)BTd@SiE4&mti9N@XYFa6kj1c@R)FmoJpFV&Q?}EG{;erPos!q!DcYMzuU$ zaUmgA<$%oMkhV5A49p50(kanAIHa?MqS``skb|YaiawFBKOvA_hxVr{#d0ohL824- zrhkS`rElB?BbfV2=OY!_h+ zMz%Cwn93FD0sj_ZHiQ2~YiNROqV$|N4r2j4mx1i~gnx|4jZJuG%H^V;hP)4qhkXn28T#j#J+|W)t~D1hIm=!!~sS5KxKAF7G;eA*X5GLftfm7r zIisz(DEsnitnqLI*4ZoMN&`sP3Gj=xxf^5sBa|@p2;J zPK3LVNk4QS`Xo7&`+%Hy?XrkDb~iyp4#WDq%`xd-lq45gV(QToO?Xj?ti|8xkDjP^ zDbb>NZ?JhUG>6vj$iW>RBOYw1>zw8v9&#s+aG_> z;+ls9n}3o!9-eEd9CEk9kAjyf_T7)WHuSV!uIGwlxhbp{w43XkQn~Ke+FaYw7WQWB ziw}8i^DHk&G>VX-z-Y(U2F4H4Us#5Av_E3cUFBnRZ@3vT>1ONWl7C?wpS>foH%A|JKvw27#SQI8Do)H z54RKgKpfKAUeu%Pk-9YN*__1AO;F$S)Y_b?KjRoZ<<+5k3Df%GrJV)!DVR^6g7I`m z_SN>LWv8o^_T21mak0{#=lw0NRa$Yzr+cZ=o^w9?D;4JycVu$z;H-Z?Zu<9j!c3;h zf_>w`JkXSos|le8Tw85Cobyzxb{7=w7%ziE{ATpD&w+Ru$Ig4IRXDnIz?|6?@)>ey zsU*qwc7Cv2p55G7#n}9%&%=1P+?0(~Pc`bCQfQ9O@!`8<>+5@X=F4BjL{7 zhX)5?VUM0ZcHE0Lugq(+pk=`@B$n{-15+>wsv3U>nn>71tFatyw2|28(9t2l%7GJy zj}DCv48@Kee#>_H0xV9)y_dZn@AZ)r$HEri&7Tg93>o*_u-o=<;y;Ky{Jk|(i%QM$@|%gSRd6 z=01%T%M)akhkrqGf%P64K77=)$Rjr`S9}o^KUVm{(g%?f+nA};+c}q|_+Xe%S)V5YP z8^8XVLJO@^Gkkn-=q<01KR@Q4oG}xpfwZ~LW5#oM$2_dwg9>b$H={G}wOd7ezklgmCdX1#_+e>$m`h`<9G0hVr z-pJ6&qlX8Eyw^`3A2>A}GNrLPEcXQ9 zx|AMbdvcXZN<0TfeQMZoT4Cz?x^+b0_zw<`o*ITT z$xCIO6}j*`W0`8^iW@BujwhAyj47!1CJbOZA}BK;7h@HKxcW zIl_*RAGSdvf6G&$$1GE-4*{+Xzs}$AZ^W&Rjt)z0&x%Iy7C1yg3?YFS*g26f*2yLk z@BqKd6^ZC20O|GB2>kwPX|?56lH*tlm7C5N z>XJuA4!@3u5V5L2mW09sfAPd!o*hBjHFS93r{2hkHx*^*lowjl=$Co|d&+g-@`0jB zxpcO`=TvR3sld@YO_vbye)@(!7shx9QMegs!(0bmE5nE;^6y+-dAif9TgTFhc?W*8 zR((&xg%MaQX-kneHMg$QZ4haT5qll?S{^YNvkVA2rWV3O+_<%@7h0{b*lV+Q1T|f| z`#~N^nA#fIk=1mC?{DrK?v*+NB2l^mNO1+T#uiVNlfzDsQdTvE8d$ ztre>fg{h?;)Izf%O(h`r+bizsB=*ytG!h4vt&cdM+G`{3s7zhN9aBIP31}S-R-w*e z;Bae=L!obPCa7@O=o_YCIFop;UwnW4!le&?AKJxJS1!JXT4CI5{T(P10X5<&Y7tL_ zOKla0Csh}MTEdg722W2R*rgt@TQ%V69_1fE3Fue{P;8{nWFwCFBe;OED!Z5Xb>|)A z{kELR7;t|kT-a|fe`5K*r5m_CL+2Mqs&`w5OZ2r1wYongg?V$8_Bu}RhU9+p zs<>eOa5GG9_jV<>??lfnseONl!2RA=&9xO%HaD=5pAC+{(UrIehk58NpsS;8G*MU4 z!pK(h;W3k-*BYV)LLjs3&*#Uu(l(HpmplZcLv$wXGw_rU;rMP4 zAxwdjHUda{AOtn)W;Z@3$(AhNu_lYy$yx$~gGW(s6%L%7kx(RWq8xf&>`8JD zn#CnLP1~rlMVWOV>^y{2O0QK;FG<*}j#hp`N-Ab1e^kz`R^CL|i2tW+kE%;zXFhBV zBj_M2N`Y8ONy2kb$yydIG842)+cUq?T)!*&;?TQ&Gvp7K*l|ptLTela%+WQnC6@}@oO9(`3qLtoKRzBt(- zRM5!P6OM5~RpJO&1VdhIQL${We|SfWysBPVmohOjg)!$4qAP^5~I zs16=cXY9`9=GqNeHj3L~Hn!uZj~y8rNuX3P^alPxI}U^Lf_=^UMw~NM9B24#6CELh zDz2exa^#$RapXf%q&DW~8mlIZ@0|%XKsElfDNe9{iJgnk6S*|4B&k@7p7Iw3cVQEoht`4N(tCX9L7c6k(m< zSk$qoiC?9z1L~^=a`@yh)nr{rl6;>wi3pPwxR;|0A95!Ey|#eLcOe^rR0wum15*@UhST--7$^bpGMo@9%N> z|K8sI=luU!d{Ax36=!l6YKP%~UdWKkeZ&Yp$gn)iKD(rqFMUM?gSiMUKEx8!SXp?Z z`SB)7yrbzSLY>!S5d~%JfsLv4lZT%8B^eWg||OhMyK$HbKdEZqp}DGn>J8=+g_Z* z%w4};YF=HAWzY4b&iUsn#WE4YY#ghHf{NH!Ux{Ulzr?2UJuq7IXqpTELRJ^yhZ|^A zoco%(!cep)In{L8diaosMTMBj{74HPiW@6)%}2|eyiC=2EEdin@#>EJb#djdi?i^5 z9@8<<(X^YK2N2H}$~93`M&!wJ-(bMN;>b1x0D%TwtbM#L)5*XnjXwEYZSqVxSIz5= zFM5EgFjrUx+(WxP=35KAqj8Vw#o8_X^L4*k$`uE4McLy;tVh%Mc2?}I(?OUpOz1Ft z5!P&ueNoQ0K$S+F`8uiSy16o@$V-O+WKnvJuzZdh50;GT=C_s`U;c&5e&Z9&V-;5C z8ljQyPZTK9-7OC`opjkc=~?Fwtwq)mY+RS;AL}oJI$x`MRW8qtrmx&gu25%)u_$2< zZgqFwKU*kKz57~s?+OqzwMO1WB( z@IT%~Z^Ey?4sXQUu9|nrgR?i~MgNC7dN`UWFGFui!eF!%bE0vMxDzwM#&}9FmBLvs zX0=b%rpL$8ujI!+wgIJZTjCxjKdbq51{2cYRhg^|@$?UmVX@oHU0jT~2R-(-AP7!{ zS#t^CX*__$at&dOV!DGG(-IymnSPhZp{iOQxiFk}ngE(L=Qh!^jO>^;yg*yJVU}5@ z+P7uOi+QVx{!VTA-psT1kJ?aOC6(XvuU}oa28!*>W+CGXGzktX(8L+dK)F=%$My1Z zP3u-;(yh(LYGnGgEgBjx$AJw3z71d~ytN$4Ufg`N0pt@Ijn(65q1K{D^q0csQ&1NRj~>5ZXNJCIlfCYypNsh1sKm z!k;`b+BujC2z4%;1W;09B$p(_)4KpTy)!&Gk`6p=MA}N>NUr9eu43G8ES>@0Q7x1vyn}~`8Y7#jRLjU%Qhw=N>iE!Gr^Zg6 zK5}$;U<_U{2Crb&;=vl$r>iAzht}v3bSWYreEv%gR8}-96Sues7*?rZp z)P$px^?Ie2>F#zQ;`b=KMqN_FLQMI~eWBX4ey37-qTa857a!D ze-7A*^fOk-XG9icz_OD-i3&CUHGnR~kk_y!W???Ws8_FEurQ#N#P`xgIp9ll6q53g zf&^H|U&o%f?0Cv+(;a7Qdl3n3O7dgI6vyqI6Dma7>L z^atkz$kz#hEaRPp7pib1(T)B|O~Lcrgm`v|&xRJmYz?|`=l1r}jYuR+GyhQPma+~g ztI1d%bp1NW`ASpIVD9Nv_Mhz!9|3gA z7(td8b8ahQgb45Ta&?M@y(7i)8LBE9LDgB$;Uhd!vyBhG5R1~ljT}{qUzV^0-ndC zJ^8o3x!?SJ9gR7SyN{b|vjSdMmw=?QK_-kVGY(Qnz;sudOW#5Nt*T?%oGDt~D^)4;K+E2jpYxtp~Hs zKdy?!;8if(0A%E+GgCPV1{3kD znW3);LzUo!F)$Q8A8rsj@u5VBTkDsN$=|_ODRAmI^`<UUe4WK`t7XyN2XXrvS-cuun}w???CEjK^kU`x_MbLpyK<`msA!f^TW_QzkaQ_sA> zZN%s+AE*#7ylmWFxVt^Kp-yGSUPFw2gxM|Lqq}i9l{o6#v&y|gPVn{(>Cv~ef;9rz z_`BSAZ@Ia6kD&4wfCpFvFC(Wp>MY#VFasz}PKM%<&6sAkxY&3wxAo|%nb(&Ls~VKm zNjTrFU2oo*ql-;iTpGpYb(zC;6|Yh6qpiDf?e5k?atDMFHs%+tnF0L}Gn21aTiN=0 z-5Qs8J0%x3d?c}*Ff9WTi8{GR(B`T4<^o!oK3xZzNW@FFK_tM#f#i-_^K`_pIfxT4 zrkB1WF!wi_H~*a)YjU=}xbaTx-~D7PFtmk6;=Bvl-PYV2ggH4$H6E}a!;=h z;Ur835oUm}NDxP=<(ZmaCCnELxVk4fmUqvTk8_yigY z1jjBg#0u^ChmRfzE#@n9tugQgS^#*RGdWxJdDtY|UJoDC{L;*LE8uCHQ0B?e&X#Vj7!tD3% zZ#{erKfzpKGrL_(Ry#_&=OLaUZ4+mn0-lN9t}kw1S(1Kxx9&^2X_S<0{^fS#|GkY7 z>yOb#qIuP!I@eYZP3TSp*%65ozrRLDdwD(+iNfd*c%kl3xf!Zjh$S%JEx=@z{TswE zVjDoZF0Tlvf+K0Rrh`lAZVGQy@%+kAQ%T!h$clr?>3lNR_`^b*>2QM=JPd=y%Y-|# zbVtVEKQ=GLdQvjrz488Ua3s*KKV3Ob_|pe}5Vd23jE&N5!k}L(Oq3`f^L(baXMcYj zak)8vck9utn&8~7qki=QcrmKBQd4&lnZ5htk%&R}Hc$?u+pibG);&1U7mP)0d^?X@ zc!<;WyvMi3?OR*D{Lo zG`4+7yT6GNMhzJNmRcY zKgoWABsH#mgQdRZh#8iaYq6p)Qv&=8H*b9z2|jg>$_=1S0ThBZD7~13CxW5uViZCw zA-;lhG-N78OB!ppNm{+O0%MUg4HLcuW3dXvm?9qXM|2<2aUw4oP9Dr;kzxGb-z|QD zd!VBzfMg2-Y~ma+shDOxc+dcU7)$nz>gU(Yg>t?K*1{{>tw~dkerw<zIcXV_>VxG6_%E~az^l5#b_`+d8mwZE`+kFJKfPH4G8Y;R0+CzwBb&kA-E zi^75UwfA=OFB{t*-3Ri7yZzWGI6w5_59kAQA;@;3z)^258D#tOlwxwOFURwyzo1k+&ILaXZ=Cn>Gn zN?0!)FFEBL=h~wbweOZz5oO;btb{H9iOgbTsK{d(+eOg4^5cJW`Q5UeN9vZ=F@o@x{+a%;JE)=8CP6`zhe zS=Ch|?^tE_3^m(xElTFDN==DSk`5jdM6EVSze_q#hW>>yUBX?pCnZcWB5doBffU@)hC5&$wd7aZIJxTR_EOz34xm?m7urr6`+4UPq)s#c_*D&LctQ7^=A`62KS z7@lz5BgxbbSq}#-qe2M=JG8Hm6hVf71TLb03uooy4U*kK99o&96jO|}bMvw6h@UIs zJ^sn6f3D!q3|;`@R2a|3Nf^4e(YW>qx8t5!7|sn7wI(2pl1yrA=P9_A=V?j;s38Cw zk8=>erj$fAH|ulH&l_uX?|juGI@?FC@?S^%>tUc@w!xvNtLt73vp?{=thfaKZB` zxqQA*n#ky}rKrtMQsN1Qr9{)I{d=qaR8&`5!8~CT)$(*H52PBy90L8F7P_mDbY=?q z`eeeJ^a~S{^@KN7DE*ZFq!f8#2c&iQ{a;i4?pcAxF9=wdF3}%{#Mg`6U2&?wI68>+`;qmdD36rlCe(8%nRQk2CG0>XV(rhj^ zN4KXiAdi*@1to>KD2|G2Pc>m=_19JeZfbizE7Fuzl&P&S#0#P1+k1_V76cy%+bRJ~ zz3AZv-i`w2A|$_oR>Q`1!AMXOymJHZ8wCu6aJErIO9&!_}kTP0!R zeHhsK+}4A8vIP?I!9taT4iIFnSePgcBQ4KT(n~xqOLNW7uAupK`{OSx0gMTA_9iPb zXd+SwPlHiFGDWTiBxxA&CknNC^+KvjfB04PBt~JMc{uJAOvjNVMB-7LN6!|q0dV;K zI7bi0ML}_Rh;HzO`UU#g7(m9mD344}Z#tb&N8&)*yCeeVziVDaR(uzEw~ABsG&M7M zqXC}SWJVlmB6t}Ufv4D5fK|a8Elz_dWQ>sp#O zH;J_@i(#;_NZ;Nx*Y8q{9^2go4iauKQCn)P-6uBk{TV)TJ7S2RJZ^sZoj&)1Cx*Vn z0*he!qK6PhDwV=+f{RMP%cw;oY*4?qKYZNy?d@H`?)C8ZWNorqD7~9ZN12NwWb1cn z$ESCYej)hFZOgbsD#xuaIbFzF6^Edl5 z&m#X{x_^H!`Ty_j>+kLD7x{mC`kwp$K8p{M7c<*mWTIF;Q_F&gKB^S+by+MiGSEoEOYvmHoz!aB^_U zZZAgCn|?Xr4V|x0p|k#khiM%~*%*yIUKf#*)I|(A25ae!l&8td zTMH#H7MR>#hzOWX7O~IFt|$YHZ7a)*>dZK(6WCDLHK#AGzdC22ye}gaxHB8*X#}L} zo^%pz`({iX%Xk0=`XL0~ZiZ1O!a}kGU?$7ucWd1am;qo*Eh#gHsuRrokCj69nIV!4jAt@p`c=gQ z`B(0*>`)_Js^XFHyhXAxV2Qfu%iJO&&&K!=+51+0a`jhew7+$A*vN zjntmr{>eSwrHF8LlJn$%jhHE-$+6~rbfCzK(SjmgIC~*R6G*fe62Pwd|3(Fwp_k}y zM!8w6N=Vx%*XO3|lVfN`qhJ%+40!n;9_b=WU0qXtX?o0V%e;+zZ>?S;i5vFfO6Aat zB4s4+G`egrtu?Mal9yzRK&HqVtjc5vz6ki3`ihm9MCB{OMS&7O1WH2k)sh}+a5bE zkt@)m;|V(y$zt0Fy_b5(+2?IW&UYfx@J<&%+c%fBUpkFbsC;IG(08CZKfwsIosMwN zOBZ=?m-b{MR=TK#yKt&OqCqYx7Ctm%>zVQDg^Ebmx`)5~q9&ZD@K)@aa*G1l!_S}0 z)oL^4YF-VTyQJ`GtWwMsO0q@5>q(1~CW4U|PNDX*T(J-UKnK6-yD;pxnlnVB#M&2I z>%WoUL#OvI<${>oFUo~ID(|E!uMN*r%nbd^*-2yZ6ddSjpfGv(b1HWp zYqKL(_?Rr-X-U6XJ%n#1zx5)gVoZ2k2>TMeU%0fL!zIbp{TLFDGv)jRk#?KfTaFok zH}Jk(eYzIwPp9LYE%Kt90MZlL9-ZWmlzLJpeH8q|I_b2wKlO`KejR8cH{e8e@$eO% z2 zwyG0tCFgBD220Yj7Y@?CH2kLr!~xtp*v1Tg;oXsAX_6>G zh6Kgo28J-wxU)cxzq|oFT~TAxoUXuI3vI { + 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 d4293f57..31b5cf4c 100644 --- a/frontend/app/(main)/admin/screenMng/reportList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/reportList/page.tsx @@ -47,7 +47,6 @@ export default function ReportManagementPage() { const [tempStartDate, setTempStartDate] = useState(undefined); const [tempEndDate, setTempEndDate] = useState(undefined); const filterRef = useRef(null); - const datePopoverRef = useRef(null); const { reports, @@ -71,10 +70,7 @@ export default function ReportManagementPage() { useEffect(() => { const handleClickOutside = (e: MouseEvent) => { - const target = e.target as Node; - const isInsideFilter = filterRef.current?.contains(target); - const isInsideDatePopover = datePopoverRef.current?.contains(target); - if (!isInsideFilter && !isInsideDatePopover) { + if (filterRef.current && !filterRef.current.contains(e.target as Node)) { setFilterOpen(false); setDatePopoverOpen(false); } @@ -227,21 +223,8 @@ export default function ReportManagementPage() { {filterOpen && datePopoverOpen && (
{ - const triggerRect = filterRef.current?.getBoundingClientRect(); - if (!triggerRect) return { top: 0, left: 0 }; - const popoverWidth = 620; - const triggerCenter = triggerRect.left + triggerRect.width / 2; - const top = triggerRect.bottom + 6; - let left = triggerCenter - popoverWidth / 2; - if (left < 8) left = 8; - if (left + popoverWidth > window.innerWidth - 8) { - left = window.innerWidth - popoverWidth - 8; - } - return { top, left }; - })()} + className="absolute top-full right-0 z-50 mt-1.5 rounded-lg border border-gray-200 bg-white shadow-lg" + onMouseDown={(e) => e.stopPropagation()} >
+ +
+
+ + + {/* 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 a9e01016..1cae8eed 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -253,7 +253,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 && ( +
등록된 템플릿이 없습니다
+ )} + + +

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

+
+ +
+ +