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/.env.example b/.env.example new file mode 100644 index 00000000..752bbbe3 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# ERP-node 환경변수 (.env.example) +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# +# 사용법: +# cp .env.example .env +# 실제 값을 채워 넣으세요 +# +# ⚠️ .env 파일은 절대 git에 커밋하지 마세요! +# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +# DB 접속 +DATABASE_URL=postgresql://postgres:YOUR_PASSWORD@YOUR_HOST:YOUR_PORT/YOUR_DB + +# 인증 +JWT_SECRET=your-jwt-secret-here +JWT_EXPIRES_IN=24h + +# 암호화 +ENCRYPTION_KEY=your-32-char-encryption-key-here + +# 외부 API 키 +KMA_API_KEY=your_kma_api_key +ITS_API_KEY=your_its_api_key +EXWAY_API_KEY=your_exway_api_key +BOK_API_KEY=your_bok_api_key +EXPRESSWAY_API_KEY= + +# CORS (프로덕션용) +CORS_ORIGIN=http://localhost:9771 diff --git a/.gitignore b/.gitignore index 97029453..e2062811 100644 --- a/.gitignore +++ b/.gitignore @@ -221,6 +221,11 @@ docs/mes-reference/ # 테스트 결과물 frontend/test-results/ +test-output/ +test-results/ + +# 아카이브/백업 파일 +*.tar.gz # Cursor 설정 .cursor/ diff --git a/PLAN.MD b/PLAN.MD deleted file mode 100644 index 49d2d7e4..00000000 --- a/PLAN.MD +++ /dev/null @@ -1,337 +0,0 @@ -# 현재 구현 계획: pop-card-list 입력 필드/계산 필드 구조 개편 - -> **작성일**: 2026-02-24 -> **상태**: 계획 완료, 코딩 대기 -> **목적**: 입력 필드 설정 단순화 + 본문 필드에 계산식 통합 + 기존 계산 필드 섹션 제거 - ---- - -## 1. 변경 개요 - -### 배경 -- 기존: "입력 필드", "계산 필드", "담기 버튼" 3개가 별도 섹션으로 분리 -- 문제: 계산 필드가 본문 필드와 동일한 위치에 표시되어야 하는데 별도 영역에 있음 -- 문제: 입력 필드의 min/max 고정값은 비실용적 (실제로는 DB 컬럼 기준 제한이 필요) -- 문제: step, columnName, sourceColumns, resultColumn 등 죽은 코드 존재 - -### 목표 -1. **본문 필드에 계산식 지원 추가** - 필드별로 "DB 컬럼" 또는 "계산식" 선택 -2. **입력 필드 설정 단순화** - 고정 min/max 제거, 제한 기준 컬럼 방식으로 변경 -3. **기존 "계산 필드" 섹션 제거** - 본문 필드에 통합되므로 불필요 -4. **죽은 코드 정리** - ---- - -## 2. 수정 대상 파일 (3개) - -### 파일 A: `frontend/lib/registry/pop-components/types.ts` - -#### 변경 A-1: CardFieldBinding 타입 확장 - -**현재 코드** (라인 367~372): -```typescript -export interface CardFieldBinding { - id: string; - columnName: string; - label: string; - textColor?: string; -} -``` - -**변경 코드**: -```typescript -export interface CardFieldBinding { - id: string; - label: string; - textColor?: string; - valueType: "column" | "formula"; // 값 유형: DB 컬럼 또는 계산식 - columnName?: string; // valueType === "column"일 때 사용 - formula?: string; // valueType === "formula"일 때 사용 (예: "$input - received_qty") - unit?: string; // 계산식일 때 단위 표시 (예: "EA") -} -``` - -**주의**: `columnName`이 required에서 optional로 변경됨. 기존 저장 데이터와의 하위 호환 필요. - -#### 변경 A-2: CardInputFieldConfig 단순화 - -**현재 코드** (라인 443~453): -```typescript -export interface CardInputFieldConfig { - enabled: boolean; - columnName?: string; - label?: string; - unit?: string; - defaultValue?: number; - min?: number; - max?: number; - maxColumn?: string; - step?: number; -} -``` - -**변경 코드**: -```typescript -export interface CardInputFieldConfig { - enabled: boolean; - label?: string; - unit?: string; - limitColumn?: string; // 제한 기준 컬럼 (해당 행의 이 컬럼 값이 최대값) - saveTable?: string; // 저장 대상 테이블 - saveColumn?: string; // 저장 대상 컬럼 - showPackageUnit?: boolean; // 포장등록 버튼 표시 여부 -} -``` - -**제거 항목**: -- `columnName` -> `saveTable` + `saveColumn`으로 대체 (명확한 네이밍) -- `defaultValue` -> 제거 (제한 기준 컬럼 값으로 대체) -- `min` -> 제거 (항상 0) -- `max` -> 제거 (`limitColumn`으로 대체) -- `maxColumn` -> `limitColumn`으로 이름 변경 -- `step` -> 제거 (키패드 방식에서 미사용) - -#### 변경 A-3: CardCalculatedFieldConfig 제거 - -**삭제**: `CardCalculatedFieldConfig` 인터페이스 전체 (라인 457~464) -**삭제**: `PopCardListConfig`에서 `calculatedField?: CardCalculatedFieldConfig;` 제거 - ---- - -### 파일 B: `frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx` - -#### 변경 B-1: 본문 필드 편집기(FieldEditor)에 값 유형 선택 추가 - -**현재**: 필드 편집 시 라벨, 컬럼, 텍스트색상만 설정 가능 - -**변경**: 값 유형 라디오("DB 컬럼" / "계산식") 추가 -- "DB 컬럼" 선택 시: 기존 컬럼 Select 표시 -- "계산식" 선택 시: 수식 입력란 + 사용 가능한 컬럼/변수 칩 목록 표시 -- 사용 가능한 변수: DB 컬럼명들 + `$input` (입력 필드 활성화 시) - -**하위 호환**: 기존 저장 데이터에 `valueType`이 없으면 `"column"`으로 기본 처리 - -#### 변경 B-2: 입력 필드 설정 섹션 개편 - -**현재 설정 항목**: 라벨, 단위, 기본값, 최소/최대, 최대값 컬럼, 저장 컬럼 - -**변경 설정 항목**: -``` -라벨 [입고 수량 ] -단위 [EA ] -제한 기준 컬럼 [ order_qty v ] -저장 대상 테이블 [ 선택 v ] -저장 대상 컬럼 [ 선택 v ] -─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ -포장등록 버튼 [on/off] -``` - -#### 변경 B-3: "계산 필드" 섹션 제거 - -**삭제**: `CalculatedFieldSettingsSection` 함수 전체 -**삭제**: 카드 템플릿 탭에서 "계산 필드" CollapsibleSection 제거 - -#### 변경 B-4: import 정리 - -**삭제**: `CardCalculatedFieldConfig` import -**추가**: 없음 (기존 import 재사용) - ---- - -### 파일 C: `frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx` - -#### 변경 C-1: FieldRow에서 계산식 필드 지원 - -**현재**: `const value = row[field.columnName]` 로 DB 값만 표시 - -**변경**: -```typescript -function FieldRow({ field, row, scaled, inputValue }: { - field: CardFieldBinding; - row: RowData; - scaled: ScaledConfig; - inputValue?: number; // 입력 필드 값 (계산식에서 $input으로 참조) -}) { - const value = field.valueType === "formula" && field.formula - ? evaluateFormula(field.formula, row, inputValue ?? 0) - : row[field.columnName ?? ""]; - // ... -} -``` - -**주의**: `inputValue`를 FieldRow까지 전달해야 하므로 CardItem -> FieldRow 경로에 prop 추가 필요 - -#### 변경 C-2: 계산식 필드 실시간 갱신 - -**현재**: 별도 `calculatedValue` useMemo가 `[calculatedField, row, inputValue]`에 반응 - -**변경**: FieldRow가 `inputValue` prop을 받으므로, `inputValue`가 변경될 때 계산식 필드가 자동으로 리렌더링됨. 별도 useMemo 불필요. - -#### 변경 C-3: 기존 calculatedField 관련 코드 제거 - -**삭제 대상**: -- `calculatedField` prop 전달 (CardItem) -- `calculatedValue` useMemo -- 계산 필드 렌더링 블록 (`{calculatedField?.enabled && calculatedValue !== null && (...)}` - -#### 변경 C-4: 입력 필드 로직 단순화 - -**변경 대상**: -- `effectiveMax`: `limitColumn` 사용, 미설정 시 999999 폴백 -- `defaultValue` 자동 초기화 로직 제거 (불필요) -- `NumberInputModal`에 포장등록 on/off 전달 - -#### 변경 C-5: NumberInputModal에 포장등록 on/off 전달 - -**현재**: 포장등록 버튼 항상 표시 -**변경**: `showPackageUnit` prop 추가, false이면 포장등록 버튼 숨김 - ---- - -### 파일 D: `frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx` - -#### 변경 D-1: showPackageUnit prop 추가 - -**현재 props**: open, onOpenChange, unit, initialValue, initialPackageUnit, min, maxValue, onConfirm - -**추가 prop**: `showPackageUnit?: boolean` (기본값 true) - -**변경**: `showPackageUnit === false`이면 포장등록 버튼 숨김 - ---- - -## 3. 구현 순서 (의존성 기반) - -| 순서 | 작업 | 파일 | 의존성 | 상태 | -|------|------|------|--------|------| -| 1 | A-1: CardFieldBinding 타입 확장 | types.ts | 없음 | [ ] | -| 2 | A-2: CardInputFieldConfig 단순화 | types.ts | 없음 | [ ] | -| 3 | A-3: CardCalculatedFieldConfig 제거 | types.ts | 없음 | [ ] | -| 4 | B-1: FieldEditor에 값 유형 선택 추가 | PopCardListConfig.tsx | 순서 1 | [ ] | -| 5 | B-2: 입력 필드 설정 섹션 개편 | PopCardListConfig.tsx | 순서 2 | [ ] | -| 6 | B-3: 계산 필드 섹션 제거 | PopCardListConfig.tsx | 순서 3 | [ ] | -| 7 | B-4: import 정리 | PopCardListConfig.tsx | 순서 6 | [ ] | -| 8 | D-1: NumberInputModal showPackageUnit 추가 | NumberInputModal.tsx | 없음 | [ ] | -| 9 | C-1: FieldRow 계산식 지원 | PopCardListComponent.tsx | 순서 1 | [ ] | -| 10 | C-3: calculatedField 관련 코드 제거 | PopCardListComponent.tsx | 순서 9 | [ ] | -| 11 | C-4: 입력 필드 로직 단순화 | PopCardListComponent.tsx | 순서 2, 8 | [ ] | -| 12 | 린트 검사 | 전체 | 순서 1~11 | [ ] | - -순서 1, 2, 3은 독립이므로 병렬 가능. -순서 8은 독립이므로 병렬 가능. - ---- - -## 4. 사전 충돌 검사 결과 - -### 새로 추가할 식별자 목록 - -| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 | -|--------|------|-----------|-----------|-----------| -| `valueType` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 | -| `formula` | CardFieldBinding 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 (기존 CardCalculatedFieldConfig.formula와 다른 인터페이스) | -| `limitColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx, PopCardListComponent.tsx | 충돌 없음 | -| `saveTable` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 | -| `saveColumn` | CardInputFieldConfig 속성 | types.ts | PopCardListConfig.tsx | 충돌 없음 | -| `showPackageUnit` | CardInputFieldConfig 속성 / NumberInputModal prop | types.ts, NumberInputModal.tsx | PopCardListComponent.tsx | 충돌 없음 | - -### 기존 타입/함수 재사용 목록 - -| 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 | -|------------|-----------|------------------------| -| `evaluateFormula()` | PopCardListComponent.tsx 라인 1026 | C-1: FieldRow에서 호출 (기존 함수 그대로 재사용) | -| `CardFieldBinding` | types.ts 라인 367 | A-1에서 수정, B-1/C-1에서 사용 | -| `CardInputFieldConfig` | types.ts 라인 443 | A-2에서 수정, B-2/C-4에서 사용 | -| `GroupedColumnSelect` | PopCardListConfig.tsx | B-1: 계산식 모드에서 컬럼 칩 표시에 재사용 가능 | - -**사용처 있는데 정의 누락된 항목: 없음** - ---- - -## 5. 에러 함정 경고 - -### 함정 1: 기존 저장 데이터 하위 호환 -기존에 저장된 `CardFieldBinding`에는 `valueType`이 없고 `columnName`이 필수였음. -**반드시** 런타임에서 `field.valueType || "column"` 폴백 처리해야 함. -Config UI에서도 `valueType` 미존재 시 `"column"` 기본값 적용 필요. - -### 함정 2: CardInputFieldConfig 하위 호환 -기존 `maxColumn`이 `limitColumn`으로 이름 변경됨. -기존 저장 데이터의 `maxColumn`을 `limitColumn`으로 읽어야 함. -런타임: `inputField?.limitColumn || (inputField as any)?.maxColumn` 폴백 필요. - -### 함정 3: evaluateFormula의 inputValue 전달 -FieldRow에 `inputValue`를 전달하려면, CardItem -> body.fields.map -> FieldRow 경로에서 `inputValue` prop을 추가해야 함. -입력 필드가 비활성화된 경우 `inputValue`는 0으로 전달. - -### 함정 4: calculatedField 제거 시 기존 데이터 -기존 config에 `calculatedField` 데이터가 남아 있을 수 있음. -타입에서 제거하더라도 런타임 에러는 나지 않음 (unknown 속성은 무시됨). -다만 이전에 계산 필드로 설정한 내용은 사라짐 - 마이그레이션 없이 제거. - -### 함정 5: columnName optional 변경 -`CardFieldBinding.columnName`이 optional이 됨. -기존에 `row[field.columnName]`으로 직접 접근하던 코드 전부 수정 필요. -`field.columnName ?? ""` 또는 valueType 분기 처리. - ---- - -## 6. 검증 방법 - -### 시나리오 1: 기존 본문 필드 (하위 호환) -1. 기존 저장된 카드리스트 열기 -2. 본문 필드에 기존 DB 컬럼 필드가 정상 표시되는지 확인 -3. 설정 패널에서 기존 필드가 "DB 컬럼" 유형으로 표시되는지 확인 - -### 시나리오 2: 계산식 본문 필드 추가 -1. 본문 필드 추가 -> 값 유형 "계산식" 선택 -2. 수식: `order_qty - received_qty` 입력 -3. 카드에서 계산 결과가 정상 표시되는지 확인 - -### 시나리오 3: $input 참조 계산식 -1. 입력 필드 활성화 -2. 본문 필드 추가 -> 값 유형 "계산식" -> 수식: `$input - received_qty` -3. 키패드에서 수량 입력 시 계산 결과가 실시간 갱신되는지 확인 - -### 시나리오 4: 제한 기준 컬럼 -1. 입력 필드 -> 제한 기준 컬럼: `order_qty` -2. order_qty=1000인 카드에서 키패드 열기 -3. MAX 버튼 클릭 시 1000이 입력되고, 1001 이상 입력 불가 확인 - -### 시나리오 5: 포장등록 on/off -1. 입력 필드 -> 포장등록 버튼: off -2. 키패드 모달에서 포장등록 버튼이 숨겨지는지 확인 - ---- - -## 이전 완료 계획 (아카이브) - -
-pop-dashboard 4가지 아이템 모드 완성 (완료) - -- [x] groupBy UI 추가 -- [x] xAxisColumn 입력 UI 추가 -- [x] 통계카드 카테고리 설정 UI 추가 -- [x] 차트 xAxisColumn 자동 보정 로직 -- [x] 통계카드 카테고리별 필터 적용 -- [x] SQL 빌더 방어 로직 -- [x] refreshInterval 최소값 강제 - -
- -
-POP 뷰어 스크롤 수정 (완료) - -- [x] overflow-hidden 제거 -- [x] overflow-auto 공통 적용 -- [x] 일반 모드 min-h-full 추가 - -
- -
-POP 뷰어 실제 컴포넌트 렌더링 (완료) - -- [x] 뷰어 페이지에 레지스트리 초기화 import 추가 -- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체 - -
diff --git a/POPUPDATE.md b/POPUPDATE.md deleted file mode 100644 index 836cdb1f..00000000 --- a/POPUPDATE.md +++ /dev/null @@ -1,1041 +0,0 @@ -# POP 화면 관리 시스템 개발 기록 - -> **AI 에이전트 안내**: 이 문서는 Progressive Disclosure 방식으로 구성되어 있습니다. -> 1. 먼저 [Quick Reference](#quick-reference)에서 필요한 정보 확인 -> 2. 상세 내용이 필요하면 해당 섹션으로 이동 -> 3. 코드가 필요하면 파일 직접 참조 - ---- - -## Quick Reference - -### POP이란? -Point of Production - 현장 작업자용 모바일/태블릿 화면 시스템 - -### 핵심 결정사항 -- **분리 방식**: 레이아웃 기반 구분 (screen_layouts_pop 테이블) -- **식별 방법**: `screen_layouts_pop`에 레코드 존재 여부로 POP 화면 판별 -- **데스크톱 영향**: 없음 (모든 isPop 기본값 = false) - -### 주요 경로 - -| 용도 | 경로 | -|------|------| -| POP 뷰어 URL | `/pop/screens/{screenId}?preview=true&device=tablet` | -| POP 관리 페이지 | `/admin/screenMng/popScreenMngList` | -| POP 레이아웃 API | `/api/screen-management/layout-pop/:screenId` | - -### 파일 찾기 가이드 - -| 작업 | 파일 | -|------|------| -| POP 레이아웃 DB 스키마 | `db/migrations/052_create_screen_layouts_pop.sql` | -| POP API 서비스 로직 | `backend-node/src/services/screenManagementService.ts` (getLayoutPop, saveLayoutPop) | -| POP API 라우트 | `backend-node/src/routes/screenManagementRoutes.ts` | -| 프론트엔드 API 클라이언트 | `frontend/lib/api/screen.ts` (screenApi.getLayoutPop 등) | -| POP 화면 관리 UI | `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` | -| POP 뷰어 페이지 | `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` | -| 미리보기 URL 분기 | `frontend/components/screen/ScreenSettingModal.tsx` (PreviewTab) | -| POP 컴포넌트 설계서 | `docs/pop/components-spec.md` (13개 컴포넌트 상세) | - ---- - -## 섹션 목차 - -| # | 섹션 | 한 줄 요약 | -|---|------|----------| -| 1 | [아키텍처](#1-아키텍처) | 레이아웃 테이블로 POP/데스크톱 분리 | -| 2 | [데이터베이스](#2-데이터베이스) | screen_layouts_pop 테이블 (FK 없음) | -| 3 | [백엔드 API](#3-백엔드-api) | CRUD 4개 엔드포인트 | -| 4 | [프론트엔드 API](#4-프론트엔드-api) | screenApi에 4개 함수 추가 | -| 5 | [관리 페이지](#5-관리-페이지) | POP 화면만 필터링하여 표시 | -| 6 | [뷰어](#6-뷰어) | 모바일/태블릿 프레임 미리보기 | -| 7 | [미리보기](#7-미리보기) | isPop prop으로 URL 분기 | -| 8 | [파일 목록](#8-파일-목록) | 생성 3개, 수정 9개 | -| 9 | [반응형 전략](#9-반응형-전략-신규-결정사항) | Flow 레이아웃 (세로 쌓기) 채택 | -| 10 | [POP 사용자 앱](#10-pop-사용자-앱-구조-신규-결정사항) | 대시보드 카드 → 화면 뷰어 | -| 11 | [POP 디자이너](#11-pop-디자이너-신규-계획) | 좌(탭패널) + 우(팬캔버스), 반응형 편집 | -| 12 | [데이터 구조](#12-pop-레이아웃-데이터-구조-신규) | PopLayoutData, mobileOverride | -| 13 | [컴포넌트 재사용성](#13-컴포넌트-재사용성-분석-신규) | 2개 재사용, 4개 부분, 7개 신규 | - ---- - -## 1. 아키텍처 - -**결정**: Option B (레이아웃 기반 구분) - -``` -screen_definitions (공용) - ├── screen_layouts_v2 (데스크톱) - └── screen_layouts_pop (POP) -``` - -**선택 이유**: 기존 테이블 변경 없음, 데스크톱 영향 없음, 향후 통합 가능 - ---- - -## 2. 데이터베이스 - -**테이블**: `screen_layouts_pop` - -| 컬럼 | 타입 | 설명 | -|------|------|------| -| id | SERIAL | PK | -| screen_id | INTEGER | 화면 ID (unique) | -| layout_data | JSONB | 컴포넌트 JSON | - -**특이사항**: FK 없음 (soft-delete 지원) - -**파일**: `db/migrations/052_create_screen_layouts_pop.sql` - ---- - -## 3. 백엔드 API - -| Method | Endpoint | 용도 | -|--------|----------|------| -| GET | `/api/screen-management/layout-pop/:screenId` | 조회 | -| POST | `/api/screen-management/layout-pop/:screenId` | 저장 | -| DELETE | `/api/screen-management/layout-pop/:screenId` | 삭제 | -| GET | `/api/screen-management/pop-layout-screen-ids` | ID 목록 | - -**파일**: `backend-node/src/services/screenManagementService.ts` - ---- - -## 4. 프론트엔드 API - -**파일**: `frontend/lib/api/screen.ts` - -```typescript -screenApi.getLayoutPop(screenId) // 조회 -screenApi.saveLayoutPop(screenId, data) // 저장 -screenApi.deleteLayoutPop(screenId) // 삭제 -screenApi.getScreenIdsWithPopLayout() // ID 목록 -``` - ---- - -## 5. 관리 페이지 - -**파일**: `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` - -**핵심 로직**: -```typescript -const popIds = await screenApi.getScreenIdsWithPopLayout(); -const filteredScreens = screens.filter(s => new Set(popIds).has(s.screenId)); -``` - -**기능**: POP 화면만 표시, 새 POP 화면 생성):, 보기/설계 버튼 - ---- - -## 6. 뷰어 - -**파일**: `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` - -**URL 파라미터**: -| 파라미터 | 값 | 설명 | -|---------|---|------| -| preview | true | 툴바 표시 | -| device | mobile/tablet | 디바이스 크기 (기본: tablet) | - -**디바이스 크기**: mobile(375x812), tablet(768x1024) - ---- - -## 7. 미리보기 - -**핵심**: `isPop` prop으로 URL 분기 - -``` -popScreenMngList - └─► ScreenRelationFlow(isPop=true) - └─► ScreenSettingModal - └─► PreviewTab → /pop/screens/{id} - -screenMngList (데스크톱) - └─► ScreenRelationFlow(isPop=false 기본값) - └─► ScreenSettingModal - └─► PreviewTab → /screens/{id} -``` - -**안전성**: isPop 기본값 = false → 데스크톱 영향 없음 - ---- - -## 8. 파일 목록 - -### 생성 (3개) - -| 파일 | 용도 | -|------|------| -| `db/migrations/052_create_screen_layouts_pop.sql` | DB 스키마 | -| `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` | POP 뷰어 | -| `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` | POP 관리 | - -### 수정 (9개) - -| 파일 | 변경 내용 | -|------|----------| -| `backend-node/src/services/screenManagementService.ts` | POP CRUD 함수 | -| `backend-node/src/controllers/screenManagementController.ts` | 컨트롤러 | -| `backend-node/src/routes/screenManagementRoutes.ts` | 라우트 | -| `frontend/lib/api/screen.ts` | API 클라이언트 | -| `frontend/components/screen/CreateScreenModal.tsx` | isPop prop | -| `frontend/components/screen/ScreenSettingModal.tsx` | isPop, PreviewTab | -| `frontend/components/screen/ScreenRelationFlow.tsx` | isPop 전달 | -| `frontend/components/screen/ScreenDesigner.tsx` | isPop, 미리보기 | -| `frontend/components/screen/toolbar/SlimToolbar.tsx` | POP 미리보기 버튼 | - ---- - -## 9. 반응형 전략 (신규 결정사항) - -### 문제점 -- 데스크톱은 절대 좌표(`position: { x, y }`) 사용 -- 모바일 화면 크기가 달라지면 레이아웃 깨짐 - -### 결정: Flow 레이아웃 채택 - -| 항목 | 데스크톱 | POP | -|-----|---------|-----| -| 배치 방식 | `position: { x, y }` | `order: number` (순서) | -| 컨테이너 | 자유 배치 | 중첩 구조 (섹션 > 필드) | -| 렌더러 | 절대 좌표 계산 | Flexbox column (세로 쌓기) | - -### Flow 레이아웃 데이터 구조 -```typescript -{ - layoutMode: "flow", // flow | absolute - components: [ - { - id: "section-1", - type: "pop-section", - order: 0, // 순서로 배치 - children: [...] - } - ] -} -``` - ---- - -## 10. POP 사용자 앱 구조 (신규 결정사항) - -### 데스크톱 vs POP 진입 구조 - -| | 데스크톱 | POP | -|---|---------|-----| -| 메뉴 | 왼쪽 사이드바 | 대시보드 카드 | -| 네비게이션 | 복잡한 트리 구조 | 화면 → 뒤로가기 | -| URL | `/screens/{id}` | `/pop/screens/{id}` | - -### POP 화면 흐름 -``` -/pop/login (POP 로그인) - ↓ -/pop/dashboard (화면 목록 - 카드형) - ↓ -/pop/screens/{id} (화면 뷰어) -``` - ---- - -## 11. POP 디자이너 (신규 계획) - -### 진입 경로 -``` -popScreenMngList → [설계] 버튼 → PopDesigner 컴포넌트 -``` - -### 레이아웃 구조 (2026-02-02 수정) -데스크톱 Screen Designer와 유사하게 **좌측 탭 패널 + 우측 캔버스**: -``` -┌─────────────────────────────────────────────────────────────────┐ -│ [툴바] ← 목록 | 화면명 | 📱모바일 📱태블릿 | 🔄 | 💾저장 │ -├────────────────┬────────────────────────────────────────────────┤ -│ [패널] │ [캔버스 영역] │ -│ ◀━━━━▶ │ │ -│ (리사이즈) │ ┌────────────────────────┐ │ -│ │ │ 디바이스 프레임 │ ← 드래그로 │ -│ ┌────────────┐ │ │ │ 팬 이동 │ -│ │컴포넌트│편집│ │ │ [섹션 1] │ │ -│ └────────────┘ │ │ ├─ 필드 A │ │ -│ │ │ └─ 필드 B │ │ -│ (컴포넌트 탭) │ │ │ │ -│ 📦 섹션 │ │ [섹션 2] │ │ -│ 📝 필드 │ │ ├─ 버튼1 ─ 버튼2 │ │ -│ 🔘 버튼 │ │ │ │ -│ 📋 리스트 │ └────────────────────────┘ │ -│ 📊 인디케이터 │ │ -│ │ │ -│ (편집 탭) │ │ -│ 선택된 컴포 │ │ -│ 넌트 설정 │ │ -└────────────────┴────────────────────────────────────────────────┘ -``` - -### 패널 기능 -| 기능 | 설명 | -|-----|------| -| **리사이즈** | 드래그로 패널 너비 조절 (min: 200px, max: 400px) | -| **컴포넌트 탭** | POP 전용 컴포넌트만 표시 | -| **편집 탭** | 선택된 컴포넌트 설정 (프리셋 기반) | - -### 캔버스 기능 -| 기능 | 설명 | -|-----|------| -| **팬(Pan)** | 마우스 드래그로 보는 위치 이동 | -| **줌** | 마우스 휠로 확대/축소 (선택사항) | -| **디바이스 탭** | 📱모바일 / 📱태블릿 전환 | -| **나란히 보기** | 옵션으로 둘 다 표시 가능 | -| **실시간 미리보기** | 편집 = 미리보기 (별도 창 불필요) | - -### 캔버스 방식: 블록 쌓기 -- 섹션끼리는 위→아래로 쌓임 -- 섹션 안에서는 가로(row) 또는 세로(column) 선택 가능 -- 드래그앤드롭으로 순서 변경 -- 캔버스 자체가 실시간 미리보기 - -### 기준 해상도 -| 디바이스 | 논리적 크기 (dp) | 용도 | -|---------|-----------------|------| -| 모바일 | 360 x 640 | Zebra TC52/57 등 산업용 핸드헬드 | -| 태블릿 | 768 x 1024 | 8~10인치 산업용 태블릿 | - -### 터치 타겟 (장갑 착용 고려) -- 최소 버튼 크기: **60dp** (일반 앱 48dp보다 큼) -- 버튼 간격: **16dp** 이상 - -### 반응형 편집 방식 -| 모드 | 설명 | -|-----|------| -| **기준 디바이스** | 태블릿 (메인 편집) | -| **자동 조정** | CSS flex-wrap, grid로 모바일 자동 줄바꿈 | -| **수동 조정** | 모바일 탭에서 그리드 열 수, 숨기기 설정 | - -**흐름:** -``` -1. 태블릿 탭에서 편집 (기준) - → 모든 컴포넌트, 섹션, 순서, 데이터 바인딩 설정 - -2. 모바일 탭에서 확인 - A) 자동 조정 OK → 그대로 저장 - B) 배치 어색함 → 그리드 열 수 조정 또는 숨기기 -``` - -### 섹션 내 컴포넌트 배치 옵션 -| 설정 | 옵션 | -|-----|------| -| 배치 방향 | `row` / `column` | -| 순서 | 드래그로 변경 | -| 비율 | flex (1:1, 2:1, 1:2 등) | -| 정렬 | `start` / `center` / `end` | -| 간격 | `none` / `small` / `medium` / `large` | -| 줄바꿈 | `wrap` / `nowrap` | -| **그리드 열 수** | 태블릿용, 모바일용 각각 설정 가능 | - -### 관리자가 설정 가능한 것 -| 항목 | 설정 방식 | -|-----|----------| -| 섹션 순서 | 드래그로 위/아래 이동 | -| 섹션 내 배치 | 가로(row) / 세로(column) | -| 정렬 | 왼쪽/가운데/오른쪽, 위/가운데/아래 | -| 컴포넌트 비율 | 1:1, 2:1, 1:2 등 (flex) | -| 크기 | S/M/L/XL 프리셋 | -| 여백/간격 | 작음/보통/넓음 프리셋 | -| 아이콘 | 선택 가능 | -| 테마/색상 | 프리셋 또는 커스텀 | -| 그리드 열 수 | 태블릿/모바일 각각 | -| 모바일 숨기기 | 특정 컴포넌트 숨김 | - -### 관리자가 설정 불가능한 것 (반응형 유지) -- 정확한 x, y 좌표 -- 정확한 픽셀 크기 (예: 347px) -- 고정 위치 (예: 왼쪽에서 100px) - -### 스타일 분리 원칙 -``` -뼈대 (변경 어려움 - 처음부터 잘 설계): -- 데이터 바인딩 구조 (columnName, dataSource) -- 컴포넌트 계층 (섹션 > 필드) -- 액션 로직 - -옷 (변경 쉬움 - 나중에 조정 가능): -- 색상, 폰트 크기 → CSS 변수/테마 -- 버튼 모양 → 프리셋 -- 아이콘 → 선택 -``` - -### 다국어 연동 (준비) -- 상태: `showMultilangSettingsModal` 미리 추가 -- 버튼: 툴바에 자리만 (비활성) -- 연결: 추후 `MultilangSettingsModal` import - -### 데스크톱 시스템 재사용 -| 기능 | 재사용 | 비고 | -|-----|-------|------| -| formData 관리 | O | 그대로 | -| 필드간 연결 | O | cascading, hierarchy | -| 테이블 참조 | O | dataSource, filter | -| 저장 이벤트 | O | beforeFormSave | -| 집계 | O | 스타일만 변경 | -| 설정 패널 | O | 탭 방식 참고 | -| CRUD API | O | 그대로 | -| buttonActions | O | 그대로 | -| 다국어 | O | MultilangSettingsModal | - -### 파일 구조 (신규 생성 예정) -``` -frontend/components/pop/ -├── PopDesigner.tsx # 메인 (좌: 패널, 우: 캔버스) -├── PopCanvas.tsx # 캔버스 (팬/줌 + 프레임) -├── PopToolbar.tsx # 상단 툴바 -│ -├── panels/ -│ └── PopPanel.tsx # 통합 패널 (컴포넌트/편집 탭) -│ -├── components/ # POP 전용 컴포넌트 -│ ├── PopSection.tsx -│ ├── PopField.tsx -│ ├── PopButton.tsx -│ └── ... -│ -└── types/ - └── pop-layout.ts # PopLayoutData, PopComponentData -``` - ---- - -## 12. POP 레이아웃 데이터 구조 (신규) - -### PopLayoutData -```typescript -interface PopLayoutData { - version: "pop-1.0"; - layoutMode: "flow"; // 항상 flow (절대좌표 없음) - deviceTarget: "mobile" | "tablet" | "both"; - components: PopComponentData[]; -} -``` - -### PopComponentData -```typescript -interface PopComponentData { - id: string; - type: "pop-section" | "pop-field" | "pop-button" | "pop-list" | "pop-indicator"; - order: number; // 순서 (x, y 좌표 대신) - - // 개별 컴포넌트 flex 비율 - flex?: number; // 기본 1 - - // 섹션인 경우: 내부 레이아웃 설정 - layout?: { - direction: "row" | "column"; - justify: "start" | "center" | "end" | "between"; - align: "start" | "center" | "end"; - gap: "none" | "small" | "medium" | "large"; - wrap: boolean; - grid?: number; // 태블릿 기준 열 수 - }; - - // 크기 프리셋 - size?: "S" | "M" | "L" | "XL" | "full"; - - // 데이터 바인딩 - dataBinding?: { - tableName: string; - columnName: string; - displayField?: string; - }; - - // 스타일 프리셋 - style?: { - variant: "default" | "primary" | "success" | "warning" | "danger"; - padding: "none" | "small" | "medium" | "large"; - }; - - // 모바일 오버라이드 (선택사항) - mobileOverride?: { - grid?: number; // 모바일 열 수 (없으면 자동) - hidden?: boolean; // 모바일에서 숨기기 - }; - - // 하위 컴포넌트 (섹션 내부) - children?: PopComponentData[]; - - // 컴포넌트별 설정 - config?: Record; -} -``` - -### 데스크톱 vs POP 데이터 비교 -| 항목 | 데스크톱 (LayoutData) | POP (PopLayoutData) | -|-----|----------------------|---------------------| -| 배치 | `position: { x, y, z }` | `order: number` | -| 크기 | `size: { width, height }` (픽셀) | `size: "S" | "M" | "L"` (프리셋) | -| 컨테이너 | 없음 (자유 배치) | `layout: { direction, grid }` | -| 반응형 | 없음 | `mobileOverride` | - ---- - -## 13. 컴포넌트 재사용성 분석 - -### 최종 분류 - -| 분류 | 개수 | 컴포넌트 | -|-----|-----|---------| -| 완전 재사용 | 2 | form-field, action-button | -| 부분 재사용 | 4 | tab-panel, data-table, kpi-gauge, process-flow | -| 신규 개발 | 7 | section, card-list, status-indicator, number-pad, barcode-scanner, timer, alarm-list | - -### 핵심 컴포넌트 7개 (최소 필수) - -| 컴포넌트 | 역할 | 포함 기능 | -|---------|------|----------| -| **pop-section** | 레이아웃 컨테이너 | 카드, 그룹핑, 접기/펼치기 | -| **pop-field** | 데이터 입력/표시 | 텍스트, 숫자, 드롭다운, 바코드, 숫자패드 | -| **pop-button** | 액션 실행 | 저장, 삭제, API 호출, 화면이동 | -| **pop-list** | 데이터 목록 | 카드리스트, 선택목록, 테이블 참조 | -| **pop-indicator** | 상태/수치 표시 | KPI, 게이지, 신호등, 진행률 | -| **pop-scanner** | 바코드/QR 입력 | 카메라, 외부 스캐너 | -| **pop-numpad** | 숫자 입력 특화 | 큰 버튼, 계산기 모드 | - ---- - -## TODO - -### Phase 1: POP 디자이너 개발 (현재 진행) - -| # | 작업 | 설명 | 상태 | -|---|------|------|------| -| 1 | `PopLayoutData` 타입 정의 | order, layout, mobileOverride | 완료 | -| 2 | `PopDesigner.tsx` | 좌: 리사이즈 패널, 우: 팬 가능 캔버스 | 완료 | -| 3 | `PopPanel.tsx` | 탭 (컴포넌트/편집), POP 컴포넌트만 | 완료 | -| 4 | `PopCanvas.tsx` | 팬/줌 + 디바이스 프레임 + 블록 렌더링 | 완료 | -| 5 | `SectionGrid.tsx` | 섹션 내부 컴포넌트 배치 (react-grid-layout) | 완료 | -| 6 | 드래그앤드롭 | 팔레트→캔버스 (섹션), 팔레트→섹션 (컴포넌트) | 완료 | -| 7 | 컴포넌트 자유 배치/리사이즈 | 고정 셀 크기(40px) 기반 자동 그리드 | 완료 | -| 8 | 편집 탭 | 그리드 설정, 모바일 오버라이드 | 완료 (기본) | -| 9 | 저장/로드 | 기존 API 재사용 (saveLayoutPop) | 완료 | - -### Phase 2: POP 컴포넌트 개발 - -상세: `docs/pop/components-spec.md` - -1단계 (우선): -- [ ] pop-section (레이아웃 컨테이너) -- [ ] pop-field (범용 입력) -- [ ] pop-button (액션) - -2단계: -- [ ] pop-list (카드형 목록) -- [ ] pop-indicator (상태/KPI) -- [ ] pop-numpad (숫자패드) - -3단계: -- [ ] pop-scanner (바코드) -- [ ] pop-timer (타이머) -- [ ] pop-alarm (알람) - -### Phase 3: POP 사용자 앱 -- [ ] `/pop/login` - POP 전용 로그인 -- [ ] `/pop/dashboard` - 화면 목록 (카드형) -- [ ] `/pop/screens/[id]` - Flow 렌더러 적용 - -### 기타 -- [ ] POP 컴포넌트 레지스트리 -- [ ] POP 메뉴/폴더 관리 -- [ ] POP 인증 분리 -- [ ] 다국어 연동 - ---- - -## 핵심 파일 참조 - -### 기존 파일 (참고용) -| 파일 | 용도 | -|------|------| -| `frontend/app/(main)/admin/screenMng/popScreenMngList/page.tsx` | 진입점, PopDesigner 호출 위치 | -| `frontend/components/screen/ScreenDesigner.tsx` | 데스크톱 디자이너 (구조 참고) | -| `frontend/components/screen/modals/MultilangSettingsModal.tsx` | 다국어 모달 (추후 연동) | -| `frontend/lib/api/screen.ts` | API (getLayoutPop, saveLayoutPop) | -| `backend-node/src/services/screenManagementService.ts` | POP CRUD (4720~4920행) | - -### 신규 생성 예정 -| 파일 | 용도 | -|------|------| -| `frontend/components/pop/PopDesigner.tsx` | 메인 디자이너 | -| `frontend/components/pop/PopCanvas.tsx` | 캔버스 (팬/줌) | -| `frontend/components/pop/PopToolbar.tsx` | 툴바 | -| `frontend/components/pop/panels/PopPanel.tsx` | 통합 패널 | -| `frontend/components/pop/types/pop-layout.ts` | 타입 정의 | -| `frontend/components/pop/components/PopSection.tsx` | 섹션 컴포넌트 | - ---- - ---- - -## 14. 그리드 시스템 단순화 (2026-02-02 변경) - -### 기존 문제: 이중 그리드 구조 -``` -캔버스 (24열, rowHeight 20px) - └─ 섹션 (colSpan/rowSpan으로 크기 지정) - └─ 내부 그리드 (columns/rows로 컴포넌트 배치) -``` - -**문제점:** -1. 섹션 크기와 내부 그리드가 독립적이라 동기화 안됨 -2. 섹션을 늘려도 내부 그리드 점은 그대로 (비례 확대만) -3. 사용자가 두 가지 단위를 이해해야 함 - -### 변경: 단일 자동계산 그리드 - -**핵심 변경사항:** -- 그리드 점(dot) 제거 -- 고정 셀 크기(40px) 기반으로 섹션 크기에 따라 열/행 수 자동 계산 -- 컴포넌트는 react-grid-layout으로 자유롭게 드래그/리사이즈 - -**코드 (SectionGrid.tsx):** -```typescript -const CELL_SIZE = 40; -const cols = Math.max(1, Math.floor((availableWidth + gap) / (CELL_SIZE + gap))); -const rows = Math.max(1, Math.floor((availableHeight + gap) / (CELL_SIZE + gap))); -``` - -**결과:** -- 섹션 크기 변경 → 내부 셀 개수 자동 조정 -- 컴포넌트 자유 배치/리사이즈 가능 -- 직관적인 사용자 경험 - -### onLayoutChange 대신 onDragStop/onResizeStop 사용 - -**문제:** onLayoutChange는 드롭 직후에도 호출되어 섹션 크기가 자동 확대됨 - -**해결:** -```typescript -// 변경 전 - - -// 변경 후 - -``` - -상태 업데이트는 드래그/리사이즈 완료 후에만 실행 - ---- - -## POP 화면 관리 페이지 개발 (2026-02-02) - -### POP 카테고리 트리 API 구현 - -**기능:** -- POP 화면을 카테고리별로 관리하는 트리 구조 구현 -- 기존 `screen_groups` 테이블을 `hierarchy_path LIKE 'POP/%'` 조건으로 필터링하여 재사용 -- 데스크탑 화면 관리와 별도로 POP 전용 카테고리 체계 구성 - -**백엔드 API:** -- `GET /api/screen-groups/pop/groups` - POP 그룹 목록 조회 -- `POST /api/screen-groups/pop/groups` - POP 그룹 생성 -- `PUT /api/screen-groups/pop/groups/:id` - POP 그룹 수정 -- `DELETE /api/screen-groups/pop/groups/:id` - POP 그룹 삭제 -- `POST /api/screen-groups/pop/ensure-root` - POP 루트 그룹 자동 생성 - -### 트러블슈팅: API 경로 중복 문제 - -**문제:** 카테고리 생성 시 404 에러 발생 - -**원인:** -- `apiClient`의 baseURL이 이미 `http://localhost:8080/api`로 설정됨 -- API 호출 경로에 `/api/screen-groups/...`를 사용하여 최종 URL이 `/api/api/screen-groups/...`로 중복 - -**해결:** -```typescript -// 변경 전 -const response = await apiClient.post("/api/screen-groups/pop/groups", data); - -// 변경 후 -const response = await apiClient.post("/screen-groups/pop/groups", data); -``` - -### 트러블슈팅: created_by 컬럼 오류 - -**문제:** `column "created_by" of relation "screen_groups" does not exist` - -**원인:** -- 신규 작성 코드에서 `created_by` 컬럼을 사용했으나 -- 기존 `screen_groups` 테이블 스키마에는 `writer` 컬럼이 존재 - -**해결:** -```sql --- 변경 전 -INSERT INTO screen_groups (..., created_by) VALUES (..., $9) - --- 변경 후 -INSERT INTO screen_groups (..., writer) VALUES (..., $9) -``` - -### 트러블슈팅: is_active 컬럼 타입 불일치 - -**문제:** `value too long for type character varying(1)` 에러로 카테고리 생성 실패 - -**원인:** -- `is_active` 컬럼이 `VARCHAR(1)` 타입 -- INSERT 쿼리에서 `true`(boolean, 4자)를 직접 사용 - -**해결:** -```sql --- 변경 전 -INSERT INTO screen_groups (..., is_active) VALUES (..., true) - --- 변경 후 -INSERT INTO screen_groups (..., is_active) VALUES (..., 'Y') -``` - -**교훈:** -- 기존 테이블 스키마를 반드시 확인 후 쿼리 작성 -- `is_active`는 `VARCHAR(1)` 타입으로 'Y'/'N' 값 사용 -- `created_by` 대신 `writer` 컬럼명 사용 - -### 카테고리 트리 UI 개선 - -**문제:** 하위 폴더와 상위 폴더의 계층 관계가 시각적으로 불명확 - -**해결:** -1. 들여쓰기 증가: `level * 16px` → `level * 24px` -2. 트리 연결 표시: "ㄴ" 문자로 하위 항목 명시 -3. 루트 폴더 강조: 주황색 아이콘 + 볼드 텍스트, 하위는 노란색 아이콘 - -```tsx -// 하위 레벨에 연결 표시 추가 -{level > 0 && ( - -)} - -// 루트와 하위 폴더 시각적 구분 - -{group.group_name} -``` - -### 미분류 화면 이동 기능 추가 - -**기능:** 미분류 화면을 특정 카테고리로 이동하는 드롭다운 메뉴 - -**구현:** -```tsx -// 이동 드롭다운 메뉴 - - - - - - {treeData.map((g) => ( - handleMoveScreenToGroup(screen, g)}> - - {g.group_name} - - ))} - - - -// API 호출 (apiClient 사용) -const handleMoveScreenToGroup = async (screen, group) => { - await apiClient.post("/screen-groups/group-screens", { - group_id: group.id, - screen_id: screen.screenId, - screen_role: "main", - display_order: 0, - is_default: false, - }); -}; -``` - -**주의:** API 호출 시 `apiClient`를 사용해야 환경별 URL이 자동 처리됨 - -### 화면 이동 로직 수정 (복사 → 이동) - -**문제:** 화면을 다른 카테고리로 이동할 때 복사가 되어 중복 발생 - -**원인:** 기존 그룹 연결 삭제 없이 새 그룹에만 연결 추가 - -**해결:** 2단계 처리 - 기존 연결 삭제 후 새 연결 추가 - -```tsx -const handleMoveScreenToGroup = async (screen: ScreenDefinition, targetGroup: PopScreenGroup) => { - // 1. 기존 연결 찾기 및 삭제 - for (const g of groups) { - const existingLink = g.screens?.find((s) => s.screen_id === screen.screenId); - if (existingLink) { - await apiClient.delete(`/screen-groups/group-screens/${existingLink.id}`); - break; - } - } - - // 2. 새 그룹에 연결 추가 - await apiClient.post("/screen-groups/group-screens", { - group_id: targetGroup.id, - screen_id: screen.screenId, - screen_role: "main", - display_order: 0, - is_default: false, - }); - - loadGroups(); // 목록 새로고침 -}; -``` - -### 화면/카테고리 메뉴 UI 개선 - -**변경 사항:** -1. 화면에 "..." 더보기 메뉴 추가 (폴더와 동일한 스타일) -2. 메뉴 항목: 설계, 위로 이동, 아래로 이동, 다른 카테고리로 이동, 그룹에서 제거 -3. 폴더 메뉴에도 위로/아래로 이동 추가 - -**순서 변경 구현:** -```tsx -// 그룹 순서 변경 (display_order 교환) -const handleMoveGroupUp = async (targetGroup: PopScreenGroup) => { - const siblingGroups = groups - .filter((g) => g.parent_id === targetGroup.parent_id) - .sort((a, b) => (a.display_order || 0) - (b.display_order || 0)); - - const currentIndex = siblingGroups.findIndex((g) => g.id === targetGroup.id); - if (currentIndex <= 0) return; - - const prevGroup = siblingGroups[currentIndex - 1]; - - await Promise.all([ - apiClient.put(`/screen-groups/groups/${targetGroup.id}`, { display_order: prevGroup.display_order }), - apiClient.put(`/screen-groups/groups/${prevGroup.id}`, { display_order: targetGroup.display_order }), - ]); - - loadGroups(); -}; - -// 화면 순서 변경 (screen_group_screens의 display_order 교환) -const handleMoveScreenUp = async (screen: ScreenDefinition, groupId: number) => { - const targetGroup = groups.find((g) => g.id === groupId); - const sortedScreens = [...targetGroup.screens].sort((a, b) => a.display_order - b.display_order); - const currentIndex = sortedScreens.findIndex((s) => s.screen_id === screen.screenId); - - if (currentIndex <= 0) return; - - const currentLink = sortedScreens[currentIndex]; - const prevLink = sortedScreens[currentIndex - 1]; - - await Promise.all([ - apiClient.put(`/screen-groups/group-screens/${currentLink.id}`, { display_order: prevLink.display_order }), - apiClient.put(`/screen-groups/group-screens/${prevLink.id}`, { display_order: currentLink.display_order }), - ]); - - loadGroups(); -}; -``` - -### 카테고리 이동 모달 (서브메뉴 → 모달 방식) - -**문제:** 카테고리가 많아지면 서브메뉴 방식은 관리 어려움 - -**해결:** 검색 기능이 있는 모달로 변경 - -**구현:** -```tsx -// 이동 모달 상태 -const [isMoveModalOpen, setIsMoveModalOpen] = useState(false); -const [movingScreen, setMovingScreen] = useState(null); -const [movingFromGroupId, setMovingFromGroupId] = useState(null); -const [moveSearchTerm, setMoveSearchTerm] = useState(""); - -// 필터링된 그룹 목록 -const filteredMoveGroups = useMemo(() => { - if (!moveSearchTerm) return flattenedGroups; - const searchLower = moveSearchTerm.toLowerCase(); - return flattenedGroups.filter((g) => - (g._displayName || g.group_name).toLowerCase().includes(searchLower) - ); -}, [flattenedGroups, moveSearchTerm]); - -// 모달 UI 특징: -// 1. 검색 입력창 (Search 아이콘 포함) -// 2. 트리 구조 표시 (depth에 따라 들여쓰기) -// 3. 현재 소속 그룹 표시 및 선택 불가 처리 -// 4. ScrollArea로 긴 목록 스크롤 지원 -``` - -**모달 구조:** -``` -┌─────────────────────────────┐ -│ 카테고리로 이동 │ -│ "화면명" 화면을 이동할... │ -├─────────────────────────────┤ -│ 🔍 카테고리 검색... │ -├─────────────────────────────┤ -│ 📁 POP 화면 │ -│ 📁 홈 관리 │ -│ 📁 출고관리 │ -│ 📁 수주관리 │ -│ 📁 생산 관리 (현재) │ -├─────────────────────────────┤ -│ [ 취소 ] │ -└─────────────────────────────┘ -``` - ---- - -## 14. 비율 기반 그리드 시스템 (2026-02-03) - -### 문제 발견 - -POP 디자이너에서 섹션을 크게 설정해도 뷰어에서 매우 얇게(약 20px) 렌더링되는 문제 발생. - -### 근본 원인 분석 - -1. **기존 구조**: `canvasGrid.rowHeight = 20` (고정 픽셀) -2. **react-grid-layout 동작**: 작은 리사이즈 → `rowSpan: 1`로 반올림 → DB 저장 -3. **뷰어 렌더링**: `gridAutoRows: 20px` → 섹션 높이 = 20px (매우 얇음) -4. **비교**: 가로(columns)는 `1fr` 비율 기반으로 잘 작동 - -### 해결책: 비율 기반 행 시스템 - -| 구분 | 이전 | 이후 | -|------|------|------| -| 타입 | `rowHeight: number` (px) | `rows: number` (개수) | -| 기본값 | `rowHeight: 20` | `rows: 24` | -| 뷰어 CSS | `gridAutoRows: 20px` | `gridTemplateRows: repeat(24, 1fr)` | -| 디자이너 계산 | 고정 20px | `resolution.height / 24` | - -### 수정된 파일 - -| 파일 | 변경 내용 | -|------|----------| -| `types/pop-layout.ts` | `PopCanvasGrid.rowHeight` → `rows`, `DEFAULT_CANVAS_GRID.rows = 24` | -| `renderers/PopLayoutRenderer.tsx` | `gridAutoRows` → `gridTemplateRows: repeat(rows, 1fr)` | -| `PopCanvas.tsx` | `rowHeight = Math.floor(resolution.height / canvasGrid.rows)` | - -### 모드별 행 높이 계산 - -| 모드 | 해상도 높이 | 행 높이 (24행 기준) | -|------|-------------|---------------------| -| tablet_landscape | 768px | 32px | -| tablet_portrait | 1024px | 42.7px | -| mobile_landscape | 375px | 15.6px | -| mobile_portrait | 667px | 27.8px | - -### 기존 데이터 호환성 - -- 기존 `rowHeight: 20` 데이터는 `rows || 24` fallback으로 처리 -- 기존 `rowSpan: 1` 데이터는 1/24 = 4.17%로 렌더링 (여전히 작음) -- **권장**: 디자이너에서 섹션 재조정 후 재저장 - ---- - -## 15. 화면 삭제 기능 추가 (2026-02-03) - -### 추가된 기능 - -POP 카테고리 트리에서 화면 자체를 삭제하는 기능 추가. - -### UI 변경 - -| 위치 | 메뉴 항목 | 동작 | -|------|----------|------| -| 그룹 내 화면 드롭다운 | "화면 삭제" | 휴지통으로 이동 | -| 미분류 화면 드롭다운 | "화면 삭제" | 휴지통으로 이동 | - -### 삭제 흐름 - -``` -1. 드롭다운 메뉴에서 "화면 삭제" 클릭 -2. 확인 다이얼로그 표시 ("삭제된 화면은 휴지통으로 이동됩니다") -3. 확인 → DELETE /api/screen-management/screens/:id -4. 화면 is_deleted = 'Y'로 변경 (soft delete) -5. 그룹 목록 새로고침 -``` - -### 완전 삭제 vs 휴지통 이동 - -| API | 동작 | 복원 가능 | -|-----|------|----------| -| `DELETE /screens/:id` | 휴지통으로 이동 (is_deleted='Y') | O | -| `DELETE /screens/:id/permanent` | DB에서 완전 삭제 | X | - -### 수정된 파일 - -| 파일 | 변경 내용 | -|------|----------| -| `PopCategoryTree.tsx` | `handleDeleteScreen`, `confirmDeleteScreen` 함수 추가 | -| `PopCategoryTree.tsx` | `isScreenDeleteDialogOpen`, `deletingScreen` 상태 추가 | -| `PopCategoryTree.tsx` | TreeNode에 `onDeleteScreen` prop 추가 | -| `PopCategoryTree.tsx` | 화면 삭제 확인 AlertDialog 추가 | - ---- - -## 16. 멀티테넌시 이슈 해결 (2026-02-03) - -### 문제 - -화면 그룹에서 제거 시 404 에러 발생. - -### 원인 - -- DB 데이터: `company_code = "*"` (최고 관리자 전용) -- 현재 세션: `company_code = "COMPANY_7"` -- 컨트롤러 WHERE 조건: `id = $1 AND company_code = $2` → 0 rows - -### 해결 - -세션 불일치 문제로 DB에서 직접 삭제 처리. - -### 교훈 - -- 최고 관리자로 생성한 데이터는 일반 회사 사용자가 삭제 불가 -- 로그인 후 토큰 갱신 필요 시 브라우저 완전 새로고침 - ---- - -## 트러블슈팅 - -### Export default doesn't exist in target module - -**문제:** `import apiClient from "@/lib/api/client"` 에러 - -**원인:** `apiClient`가 named export로 정의됨 - -**해결:** `import { apiClient } from "@/lib/api/client"` 사용 - -### 섹션이 매우 얇게 렌더링되는 문제 - -**문제:** 디자이너에서 크게 설정한 섹션이 뷰어에서 20px 높이로 표시 - -**원인:** `canvasGrid.rowHeight = 20` 고정값 + react-grid-layout의 rowSpan 반올림 - -**해결:** 비율 기반 rows 시스템으로 변경 (섹션 14 참조) - -### 화면 삭제 404 에러 - -**문제:** 화면 그룹에서 제거 시 404 에러 - -**원인:** company_code 불일치 (세션 vs DB) - -**해결:** 브라우저 새로고침으로 토큰 갱신 또는 DB 직접 처리 - -### 관련 파일 - -| 파일 | 역할 | -|------|------| -| `frontend/components/pop/management/PopCategoryTree.tsx` | POP 카테고리 트리 (전체 UI) | -| `frontend/lib/api/popScreenGroup.ts` | POP 그룹 API 클라이언트 | -| `backend-node/src/controllers/screenGroupController.ts` | 그룹 CRUD 컨트롤러 | -| `backend-node/src/routes/screenGroupRoutes.ts` | 그룹 API 라우트 | -| `frontend/components/pop/designer/types/pop-layout.ts` | POP 레이아웃 타입 정의 | -| `frontend/components/pop/designer/renderers/PopLayoutRenderer.tsx` | CSS Grid 기반 렌더러 | -| `frontend/components/pop/designer/PopCanvas.tsx` | react-grid-layout 디자이너 캔버스 | - ---- - -*최종 업데이트: 2026-02-03* diff --git a/POPUPDATE_2.md b/POPUPDATE_2.md deleted file mode 100644 index 85e20af2..00000000 --- a/POPUPDATE_2.md +++ /dev/null @@ -1,696 +0,0 @@ -# POP 컴포넌트 정의서 v8.0 - -## POP 헌법 (공통 규칙) - -### 제1조. 컴포넌트의 정의 - -- 컴포넌트란 디자이너가 그리드에 배치하는 것이다 -- 그리드에 배치하지 않는 것은 컴포넌트가 아니다 - -### 제2조. 컴포넌트의 독립성 - -- 모든 컴포넌트는 독립적으로 동작한다 -- 컴포넌트는 다른 컴포넌트의 존재를 직접 알지 못한다 (이벤트 버스로만 통신) - -### 제3조. 데이터의 자유 - -- 모든 컴포넌트는 자신의 테이블 + 외부 테이블을 자유롭게 조인할 수 있다 -- 컬럼별로 read/write/readwrite/hidden을 개별 설정할 수 있다 -- 보유 데이터 중 원하는 컬럼만 골라서 저장할 수 있다 - -### 제4조. 통신의 규칙 - -- 컴포넌트 간 통신은 반드시 이벤트 버스(usePopEvent)를 통한다 -- 컴포넌트가 다른 컴포넌트를 직접 참조하거나 호출하지 않는다 -- 이벤트는 화면 단위로 격리된다 (다른 POP 화면의 이벤트를 받지 않는다) -- 같은 화면 안에서는 이벤트를 통해 자유롭게 데이터를 주고받을 수 있다 - -### 제5조. 역할의 분리 - -- 조회용 입력(pop-search)과 저장용 입력(pop-field)은 다른 컴포넌트다 -- 이동/실행(pop-icon)과 값 선택 후 반환(pop-lookup)은 다른 컴포넌트다 -- 자주 쓰는 패턴은 하나로 합치되, 흐름 자체는 강제하고 보이는 방식만 옵션으로 제공한다 - -### 제6조. 시스템 설정도 컴포넌트다 - -- 프로필, 테마, 대시보드 보이기/숨기기 같은 시스템 설정도 컴포넌트(pop-system)로 만든다 -- 디자이너가 pop-system을 배치하지 않으면 해당 화면에 설정 기능이 없다 -- 이를 통해 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정한다 - -### 제7조. 디자이너의 권한 - -- 디자이너는 컴포넌트를 배치하고 설정한다 -- 디자이너는 사용자에게 커스텀을 허용할지 말지 결정한다 (userConfigurable) -- 디자이너가 "사용자 커스텀 허용 = OFF"로 설정하면, 사용자는 변경할 수 없다 -- 컴포넌트의 옵션 설정(어떻게 저장하고 어떻게 조회하는지 등)은 디자이너가 결정한다 - -### 제8조. 컴포넌트의 구성 - -- 모든 컴포넌트는 3개 파일로 구성된다: 실제 컴포넌트, 디자인 미리보기, 설정 패널 -- 모든 컴포넌트는 레지스트리에 등록해야 디자이너에 나타난다 -- 모든 컴포넌트 인스턴스는 userConfigurable, displayName 공통 속성을 가진다 - -### 제9조. 모달 화면의 설계 - -- 모달은 인라인(컴포넌트 설정만으로 구성)과 외부 참조(별도 POP 화면 연결) 두 가지 방식이 있다 -- 단순한 목록 선택은 인라인 모달을 사용한다 (설정만으로 완결) -- 복잡한 검색/필터가 필요하거나 여러 곳에서 재사용하는 모달은 별도 POP 화면을 만들어 참조한다 -- 모달 안의 화면도 동일한 POP 컴포넌트 시스템으로 구성된다 (같은 그리드, 같은 컴포넌트) -- 모달 화면의 layout_data는 기존 screen_layouts_pop 테이블에 저장한다 (DB 변경 불필요) - ---- - -## 현재 상태 - -- 그리드 시스템 (v5.2): 완성 -- 컴포넌트 레지스트리: 완성 (PopComponentRegistry.ts) -- 구현 완료: `pop-text` 1개 (pop-text.tsx) -- 기존 `components-spec.md`는 v4 기준이라 갱신 필요 - -## 아키텍처 개요 - -```mermaid -graph TB - subgraph designer [디자이너] - Palette[컴포넌트 팔레트] - Grid[CSS Grid 캔버스] - ConfigPanel[속성 설정 패널] - end - - subgraph registry [레지스트리] - Registry[PopComponentRegistry] - end - - subgraph infra [공통 인프라] - DataSource[useDataSource 훅] - EventBus[usePopEvent 훅] - ActionRunner[usePopAction 훅] - end - - subgraph components [9개 컴포넌트] - Text[pop-text - 완성] - Dashboard[pop-dashboard] - Table[pop-table] - Button[pop-button] - Icon[pop-icon] - Search[pop-search] - Field[pop-field] - Lookup[pop-lookup] - System[pop-system] - end - - subgraph backend [기존 백엔드 API] - DataAPI[dataApi - 동적 CRUD] - DashAPI[dashboardApi - 통계 쿼리] - CodeAPI[commonCodeApi - 공통코드] - NumberAPI[numberingRuleApi - 채번] - end - - Palette --> Grid - Grid --> ConfigPanel - ConfigPanel --> Registry - - Registry --> components - components --> infra - infra --> backend - EventBus -.->|컴포넌트 간 통신| components - System -.->|보이기/숨기기 제어| components -``` - ---- - -## 공통 인프라 (모든 컴포넌트가 공유) - -### 핵심 원칙: 모든 컴포넌트는 데이터를 자유롭게 다룬다 - -1. **데이터 전달**: 모든 컴포넌트는 자신이 보유한 데이터를 다른 컴포넌트에 전달/수신 가능 -2. **테이블 조인**: 자신의 테이블 + 외부 테이블 자유롭게 조인하여 데이터 구성 -3. **컬럼별 CRUD 제어**: 컬럼 단위로 "조회만" / "저장 대상" / "숨김"을 개별 설정 가능 -4. **선택적 저장**: 보유 데이터 중 원하는 컬럼만 골라서 저장/수정/삭제 가능 - -### 공통 인스턴스 속성 (모든 컴포넌트 배치 시 설정 가능) - -디자이너가 컴포넌트를 그리드에 배치할 때 설정하는 공통 속성: - -- `userConfigurable`: boolean - 사용자가 이 컴포넌트를 숨길 수 있는지 (개인 설정 패널에 노출) -- `displayName`: string - 개인 설정 패널에 보여줄 이름 (예: "금일 생산실적") - -### 1. DataSourceConfig (데이터 소스 설정 타입) - -모든 데이터 연동 컴포넌트가 사용하는 표준 설정 구조: - -- `tableName`: 대상 테이블 -- `columns`: 컬럼 바인딩 목록 (ColumnBinding 배열) -- `filters`: 필터 조건 배열 -- `sort`: 정렬 설정 -- `aggregation`: 집계 함수 (count, sum, avg, min, max) -- `joins`: 테이블 조인 설정 (JoinConfig 배열) -- `refreshInterval`: 자동 새로고침 주기 (초) -- `limit`: 조회 건수 제한 - -### 1-1. ColumnBinding (컬럼별 읽기/쓰기 제어) - -각 컬럼이 컴포넌트에서 어떤 역할을 하는지 개별 설정: - -- `columnName`: 컬럼명 -- `sourceTable`: 소속 테이블 (조인된 외부 테이블 포함) -- `mode`: "read" | "write" | "readwrite" | "hidden" - - read: 조회만 (화면에 표시하되 저장 안 함) - - write: 저장 대상 (사용자 입력 -> DB 저장) - - readwrite: 조회 + 저장 모두 - - hidden: 내부 참조용 (화면에 안 보이지만 다른 컴포넌트에 전달 가능) -- `label`: 화면 표시 라벨 -- `defaultValue`: 기본값 - -예시: 발주 품목 카드에서 5개 컬럼 중 3개만 저장 - -``` -columns: [ - { columnName: "item_code", sourceTable: "order_items", mode: "read" }, - { columnName: "item_name", sourceTable: "item_info", mode: "read" }, - { columnName: "inbound_qty", sourceTable: "order_items", mode: "readwrite" }, - { columnName: "warehouse", sourceTable: "order_items", mode: "write" }, - { columnName: "memo", sourceTable: "order_items", mode: "write" }, -] -``` - -### 1-2. JoinConfig (테이블 조인 설정) - -외부 테이블과 자유롭게 조인: - -- `targetTable`: 조인할 외부 테이블명 -- `joinType`: "inner" | "left" | "right" -- `on`: 조인 조건 { sourceColumn, targetColumn } -- `columns`: 가져올 컬럼 목록 - -### 2. useDataSource 훅 - -DataSourceConfig를 받아서 기존 API를 호출하고 결과를 반환: - -- 로딩/에러/데이터 상태 관리 -- 자동 새로고침 타이머 -- 필터 변경 시 자동 재조회 -- 기존 `dataApi`, `dashboardApi` 활용 -- **CRUD 함수 제공**: save(data), update(id, data), delete(id) - - ColumnBinding의 mode가 "write" 또는 "readwrite"인 컬럼만 저장 대상에 포함 - - "read" 컬럼은 저장 시 자동 제외 - -### 3. usePopEvent 훅 (이벤트 버스 - 데이터 전달 포함) - -컴포넌트 간 통신 (단순 이벤트 + 데이터 페이로드): - -- `publish(eventName, payload)`: 이벤트 발행 -- `subscribe(eventName, callback)`: 이벤트 구독 -- `getSharedData(key)`: 공유 데이터 직접 읽기 -- `setSharedData(key, value)`: 공유 데이터 직접 쓰기 -- 화면 단위 스코프 (다른 POP 화면과 격리) - -### 4. PopActionConfig (액션 설정 타입) - -모든 컴포넌트가 사용할 수 있는 액션 표준 구조: - -- `type`: "navigate" | "modal" | "save" | "delete" | "api" | "event" | "refresh" -- `navigate`: { screenId, url } -- `modal`: { mode, title, screenId, inlineConfig, modalSize } - - mode: "inline" (설정만으로 구성) | "screen-ref" (별도 화면 참조) - - title: 모달 제목 - - screenId: mode가 "screen-ref"일 때 참조할 POP 화면 ID - - inlineConfig: mode가 "inline"일 때 사용할 DataSourceConfig + 표시 설정 - - modalSize: { width, height } 모달 크기 -- `save`: { targetColumns } -- `delete`: { confirmMessage } -- `api`: { method, endpoint, body } -- `event`: { eventName, payload } -- `refresh`: { targetComponents } - ---- - -## 컴포넌트 정의 (9개) - -### 1. pop-text (완성) - -- **한 줄 정의**: 보여주기만 함 -- **카테고리**: display -- **역할**: 정적 표시 전용 (이벤트 없음) -- **서브타입**: text, datetime, image, title -- **데이터**: 없음 (정적 콘텐츠) -- **이벤트**: 발행 없음, 수신 없음 -- **설정**: 내용, 폰트 크기/굵기, 좌우/상하 정렬, 이미지 URL/맞춤/크기, 날짜 포맷 빌더 - -### 2. pop-dashboard (신규 - 2026-02-09 토의 결과 반영) - -- **한 줄 정의**: 여러 집계 아이템을 묶어서 다양한 방식으로 보여줌 -- **카테고리**: display -- **역할**: 숫자 데이터를 집계/계산하여 시각화. 하나의 컴포넌트 안에 여러 집계 아이템을 담는 컨테이너 -- **구조**: 1개 pop-dashboard = 여러 DashboardItem의 묶음. 각 아이템은 독립적으로 데이터 소스/서브타입/보이기숨기기 설정 가능 -- **서브타입** (아이템별로 선택, 한 묶음에 혼합 가능): - - kpi-card: 숫자 + 단위 + 라벨 + 증감 표시 - - chart: 막대/원형/라인 차트 - - gauge: 게이지 (목표 대비 달성률) - - stat-card: 통계 카드 (건수 + 대기 + 링크) -- **표시 모드** (디자이너가 선택): - - arrows: 좌우 버튼으로 아이템 넘기기 - - auto-slide: 전광판처럼 자동 전환 (터치 시 멈춤, 일정 시간 후 재개) - - grid: 컴포넌트 영역 내부를 행/열로 쪼개서 여러 아이템 동시 표시 (디자이너가 각 아이템 위치 직접 지정) - - scroll: 좌우 또는 상하 스와이프 -- **데이터**: 각 아이템별 독립 DataSourceConfig (조인/집계 자유) -- **계산식 지원**: "생산량/총재고량", "출고량/현재고량" 같은 복합 표현 가능 - - 값 A, B를 각각 다른 테이블/집계로 설정 - - 표시 형태: 분수(1,234/5,678), 퍼센트(21.7%), 비율(1,234:5,678) -- **CRUD**: 주로 읽기. 목표값 수정 등 필요 시 write 컬럼으로 저장 가능 -- **이벤트**: - - 수신: filter_changed, data_ready - - 발행: kpi_clicked (아이템 클릭 시 상세 데이터 전달) -- **설정**: 데이터 소스(드롭다운 기반 쉬운 집계), 집계 함수, 계산식, 라벨, 단위, 색상 구간, 차트 타입, 새로고침 주기, 목표값, 표시 모드, 아이템별 보이기/숨기기 -- **보이기/숨기기**: 각 아이템별로 pop-system에서 개별 on/off 가능 (userConfigurable) -- **기존 POP 대시보드 폐기**: `frontend/components/pop/dashboard/` 폴더 전체를 이 컴포넌트로 대체 예정 (Phase 1~3 완료 후) - -#### pop-dashboard 데이터 구조 - -``` -PopDashboardConfig { - items: DashboardItem[] // 아이템 목록 (각각 독립 설정) - displayMode: "arrows" | "auto-slide" | "grid" | "scroll" - autoSlideInterval: number // 자동 슬라이드 간격(초) - gridLayout: { columns: number, rows: number } // 행열 그리드 설정 - showIndicator: boolean // 페이지 인디케이터 표시 - gap: number // 아이템 간 간격 -} - -DashboardItem { - id: string - label: string // pop-system에서 보이기/숨기기용 이름 - visible: boolean // 보이기/숨기기 - subType: "kpi-card" | "chart" | "gauge" | "stat-card" - dataSource: DataSourceConfig // 각 아이템별 독립 데이터 소스 - - // 행열 그리드 모드에서의 위치 (디자이너가 직접 지정) - gridPosition: { col: number, row: number, colSpan: number, rowSpan: number } - - // 계산식 (선택사항) - formula?: { - enabled: boolean - values: [ - { id: "A", dataSource: DataSourceConfig, label: "생산량" }, - { id: "B", dataSource: DataSourceConfig, label: "총재고량" }, - ] - expression: string // "A / B", "A + B", "A / B * 100" - displayFormat: "value" | "fraction" | "percent" | "ratio" - } - - // 서브타입별 설정 - kpiConfig?: { unit, colorRanges, showTrend, trendPeriod } - chartConfig?: { chartType, xAxis, yAxis, colors } - gaugeConfig?: { min, max, target, colorRanges } - statConfig?: { categories, showLink } -} -``` - -#### 설정 패널 흐름 (드롭다운 기반 쉬운 집계) - -``` -1. [+ 아이템 추가] 버튼 클릭 -2. 서브타입 선택: kpi-card / chart / gauge / stat-card -3. 데이터 모드 선택: [단일 집계] 또는 [계산식] - - [단일 집계] - - 테이블 선택 (table-schema API로 목록) - - 조인할 테이블 추가 (선택사항) - - 컬럼 선택 → 집계 함수 선택 (합계/건수/평균/최소/최대) - - 필터 조건 추가 - - [계산식] (예: 생산량/총재고량) - - 값 A: 테이블 -> 컬럼 -> 집계함수 - - 값 B: 테이블 -> 컬럼 -> 집계함수 (다른 테이블도 가능) - - 계산식: A / B - - 표시 형태: 분수 / 퍼센트 / 비율 - -4. 라벨, 단위, 색상 등 외형 설정 -5. 행열 그리드 위치 설정 (grid 모드일 때) -``` - -### 3. pop-table (신규 - 가장 복잡) - -- **한 줄 정의**: 데이터 목록을 보여주고 편집함 -- **카테고리**: display -- **역할**: 데이터 목록 표시 + 편집 (카드형/테이블형) -- **서브타입**: - - card-list: 카드 형태 - - table-list: 테이블 형태 (행/열 장부) -- **데이터**: DataSourceConfig (조인/컬럼별 읽기쓰기 자유) -- **CRUD**: useDataSource의 save/update/delete 사용. write/readwrite 컬럼만 자동 추출 -- **카드 템플릿** (card-list 전용): 카드 내부 미니 그리드로 요소 배치, 요소별 데이터 바인딩 -- **이벤트**: - - 수신: filter_changed, refresh, data_ready - - 발행: row_selected, row_action, save_complete, delete_complete -- **설정**: 데이터 소스, 표시 모드, 카드 템플릿, 컬럼 정의, 행 선택 방식, 페이징, 정렬, 인라인 편집 여부 - -### 4. pop-button (신규) - -- **한 줄 정의**: 누르면 액션 실행 (저장, 삭제 등) -- **카테고리**: action -- **역할**: 액션 실행 (저장, 삭제, API 호출, 모달 열기 등) -- **데이터**: 이벤트로 수신한 데이터를 액션에 활용 -- **CRUD**: 버튼 클릭 시 수신 데이터 기반으로 save/update/delete 실행 -- **이벤트**: - - 수신: data_ready, row_selected - - 발행: save_complete, delete_complete 등 -- **설정**: 라벨, 아이콘, 크기, 스타일, 액션 설정(PopActionConfig), 확인 다이얼로그, 로딩 상태 - -### 5. pop-icon (신규) - -- **한 줄 정의**: 누르면 어딘가로 이동 (돌아오는 값 없음) -- **카테고리**: action -- **역할**: 네비게이션 (화면 이동, URL 이동) -- **데이터**: 없음 -- **이벤트**: 없음 (네비게이션은 이벤트가 아닌 직접 실행) -- **설정**: 아이콘 종류(lucide-icon), 라벨, 배경색/그라디언트, 크기, 클릭 액션(PopActionConfig), 뱃지 표시 -- **pop-lookup과의 차이**: pop-icon은 이동/실행만 함. 값을 선택해서 돌려주지 않음 - -### 6. pop-search (신규) - -- **한 줄 정의**: 조건을 입력해서 다른 컴포넌트를 조회/필터링 -- **카테고리**: input -- **역할**: 다른 컴포넌트에 필터 조건 전달 + 자체 데이터 조회 -- **서브타입**: - - text-search: 텍스트 검색 - - date-range: 날짜 범위 - - select-filter: 드롭다운 선택 (공통코드 연동) - - combo-filter: 복합 필터 (여러 조건 조합) -- **실행 방식**: auto(값 변경 즉시) 또는 button(검색 버튼 클릭 시) -- **데이터**: 공통코드/카테고리 API로 선택 항목 조회 -- **이벤트**: - - 수신: 없음 - - 발행: filter_changed (필터 값 변경 시) -- **설정**: 필터 타입, 대상 컬럼, 공통코드 연결, 플레이스홀더, 실행 방식(auto/button), 발행할 이벤트 이름 -- **pop-field와의 차이**: pop-search 입력값은 조회용(DB에 안 들어감). pop-field 입력값은 저장용(DB에 들어감) - -### 7. pop-field (신규) - -- **한 줄 정의**: 저장할 값을 입력 -- **카테고리**: input -- **역할**: 단일 데이터 입력 (폼 필드) - 입력한 값이 DB에 저장되는 것이 목적 -- **서브타입**: - - text: 텍스트 입력 - - number: 숫자 입력 (수량, 금액) - - date: 날짜 선택 - - select: 드롭다운 선택 - - numpad: 큰 숫자패드 (현장용) -- **데이터**: DataSourceConfig (선택적) - - select 옵션을 DB에서 조회 가능 - - ColumnBinding으로 입력값의 저장 대상 테이블/컬럼 지정 -- **CRUD**: 자체 저장은 보통 하지 않음. value_changed 이벤트로 pop-button 등에 전달 -- **이벤트**: - - 수신: set_value (외부에서 값 설정) - - 발행: value_changed (값 + 컬럼명 + 모드 정보) -- **설정**: 입력 타입, 라벨, 플레이스홀더, 필수 여부, 유효성 검증, 최소/최대값, 단위 표시, 바인딩 컬럼 - -### 8. pop-lookup (신규) - -- **한 줄 정의**: 모달에서 값을 골라서 반환 -- **카테고리**: input -- **역할**: 필드를 클릭하면 모달이 열리고, 목록에서 선택하면 값이 반환되는 컴포넌트 -- **서브타입 (모달 안 표시 방식)**: - - card: 카드형 목록 - - table: 테이블형 목록 - - icon-grid: 아이콘 그리드 (참조 화면의 거래처 선택처럼) -- **동작 흐름**: 필드 클릭 -> 모달 열림 -> 목록에서 선택 -> 모달 닫힘 -> 필드에 값 표시 + 이벤트 발행 -- **데이터**: DataSourceConfig (모달 안 목록의 데이터 소스) -- **이벤트**: - - 수신: set_value (외부에서 값 초기화) - - 발행: value_selected (선택한 레코드 전체 데이터 전달), filter_changed (선택 값을 필터로 전달) -- **설정**: 라벨, 플레이스홀더, 데이터 소스, 모달 표시 방식(card/table/icon-grid), 표시 컬럼(모달 목록에 보여줄 컬럼), 반환 컬럼(선택 시 돌려줄 값), 발행할 이벤트 이름 -- **pop-icon과의 차이**: pop-icon은 이동/실행만 하고 값이 안 돌아옴. pop-lookup은 값을 골라서 돌려줌 -- **pop-search와의 차이**: pop-search는 텍스트/날짜/드롭다운으로 필터링. pop-lookup은 모달을 열어서 목록에서 선택 - -#### pop-lookup 모달 화면 설계 방식 - -pop-lookup이 열리는 모달의 내부 화면은 **두 가지 방식** 중 선택할 수 있다: - -**방식 A: 인라인 모달 (기본)** -- pop-lookup 컴포넌트의 설정 패널에서 직접 모달 내부 화면을 구성 -- DataSourceConfig + 표시 컬럼 + 검색 필터 설정만으로 동작 -- 별도 화면 생성 없이 컴포넌트 설정만으로 완결 -- 적합한 경우: 단순 목록 선택 (거래처 목록, 품목 목록 등) - -**방식 B: 외부 화면 참조 (고급)** -- 별도의 POP 화면(screen_id)을 모달로 연결 -- 모달 안에서 검색/필터/테이블 등 복잡한 화면을 디자이너로 자유롭게 구성 -- 여러 pop-lookup에서 같은 모달 화면을 재사용 가능 -- 적합한 경우: 복잡한 검색/필터가 필요한 선택 화면, 여러 화면에서 공유하는 모달 - -**설정 구조:** - -``` -modalConfig: { - mode: "inline" | "screen-ref" - - // mode = "inline"일 때 사용 - dataSource: DataSourceConfig - displayColumns: ColumnBinding[] - searchFilter: { enabled: boolean, targetColumns: string[] } - modalSize: { width: number, height: number } - - // mode = "screen-ref"일 때 사용 - screenId: number // 참조할 POP 화면 ID - returnMapping: { // 모달 화면에서 선택된 값을 어떻게 매핑할지 - sourceColumn: string // 모달 화면에서 반환하는 컬럼 - targetField: string // pop-lookup 필드에 표시할 값 - }[] - modalSize: { width: number, height: number } -} -``` - -**기존 시스템과의 호환성 (검증 완료):** - -| 항목 | 현재 상태 | pop-lookup 지원 여부 | -|------|-----------|---------------------| -| DB: layout_data JSONB | 유연한 JSON 구조 | modalConfig를 layout_data에 저장 가능 (스키마 변경 불필요) | -| DB: screen_layouts_pop 테이블 | screen_id + company_code 기반 | 모달 화면도 별도 screen_id로 저장 가능 | -| 프론트: TabsWidget | screenId로 외부 화면 참조 지원 | 같은 패턴으로 모달에서 외부 화면 로드 가능 | -| 프론트: detectLinkedModals API | 연결된 모달 화면 감지 기능 있음 | 화면 간 참조 관계 추적에 활용 가능 | -| 백엔드: saveLayoutPop/getLayoutPop | POP 전용 저장/조회 API 있음 | 모달 화면도 동일 API로 저장/조회 가능 | -| 레이어 시스템 | layer_id 기반 다중 레이어 지원 | 모달 내부 레이아웃을 레이어로 관리 가능 | - -**DB 마이그레이션 불필요**: layout_data가 JSONB이므로 modalConfig를 컴포넌트 overrides에 포함하면 됨. -**백엔드 변경 불필요**: 기존 saveLayoutPop/getLayoutPop API가 그대로 사용 가능. -**프론트엔드 참고 패턴**: TabsWidget의 screenId 참조 방식을 그대로 차용. - -### 9. pop-system (신규) - -- **한 줄 정의**: 시스템 설정을 하나로 통합한 컴포넌트 (프로필, 테마, 보이기/숨기기) -- **카테고리**: system -- **역할**: 사용자 개인 설정 기능을 제공하는 통합 컴포넌트 -- **내부 포함 기능**: - - 프로필 표시 (사용자명, 부서) - - 테마 선택 (기본/다크/블루/그린) - - 대시보드 보이기/숨기기 체크박스 (같은 화면의 userConfigurable=true 컴포넌트를 자동 수집) - - 하단 메뉴 보이기/숨기기 - - 드래그앤드롭으로 순서 변경 -- **디자이너가 설정하는 것**: 크기(그리드에서 차지하는 영역), 내부 라벨/아이콘 크기와 위치 -- **사용자가 하는 것**: 체크박스로 컴포넌트 보이기/숨기기, 테마 선택, 순서 변경 -- **데이터**: 같은 화면의 layout_data에서 컴포넌트 목록을 자동 수집 -- **저장**: 사용자별 설정을 localStorage에 저장 (데스크탑 패턴 따름) -- **이벤트**: - - 수신: 없음 - - 발행: visibility_changed (컴포넌트 보이기/숨기기 변경 시), theme_changed (테마 변경 시) -- **설정**: 내부 라벨 크기, 아이콘 크기, 위치 정도만 -- **특이사항**: - - 디자이너가 이 컴포넌트를 배치하지 않으면 해당 화면에 개인 설정 기능이 없다 - - 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정하는 구조 - - 메인 홈에는 배치, 업무 화면(입고 등)에는 안 배치하는 식으로 사용 - ---- - -## 컴포넌트 간 통신 예시 - -### 예시 1: 검색 -> 필터 연동 - -```mermaid -sequenceDiagram - participant Search as pop-search - participant Dashboard as pop-dashboard - participant Table as pop-table - - Note over Search: 사용자가 창고 WH01 선택 - Search->>Dashboard: filter_changed - Search->>Table: filter_changed - Note over Dashboard: DataSource 재조회 - Note over Table: DataSource 재조회 -``` - -### 예시 2: 데이터 전달 + 선택적 저장 - -```mermaid -sequenceDiagram - participant Table as pop-table - participant Field as pop-field - participant Button as pop-button - - Note over Table: 사용자가 발주 행 선택 - Table->>Field: row_selected - Table->>Button: row_selected - Note over Field: 사용자가 qty를 500으로 입력 - Field->>Button: value_changed - Note over Button: 사용자가 저장 클릭 - Note over Button: write/readwrite 컬럼만 추출하여 저장 - Button->>Table: save_complete - Note over Table: 데이터 새로고침 -``` - -### 예시 3: pop-lookup 거래처 선택 -> 품목 조회 - -```mermaid -sequenceDiagram - participant Lookup as pop-lookup - participant Table as pop-table - - Note over Lookup: 사용자가 거래처 필드 클릭 - Note over Lookup: 모달 열림 - 거래처 목록 표시 - Note over Lookup: 사용자가 대한금속 선택 - Note over Lookup: 모달 닫힘 - 필드에 대한금속 표시 - Lookup->>Table: filter_changed { company: "대한금속" } - Note over Table: company=대한금속 필터로 재조회 - Note over Table: 발주 품목 3건 표시 -``` - -### 예시 4: pop-lookup 인라인 모달 vs 외부 화면 참조 - -```mermaid -sequenceDiagram - participant User as 사용자 - participant Lookup as pop-lookup (거래처) - participant Modal as 모달 - - Note over User,Modal: [방식 A: 인라인 모달] - User->>Lookup: 거래처 필드 클릭 - Lookup->>Modal: 인라인 모달 열림 (DataSourceConfig 기반) - Note over Modal: supplier 테이블에서 목록 조회 - Note over Modal: 테이블형 목록 표시 - User->>Modal: "대한금속" 선택 - Modal->>Lookup: value_selected { supplier_code: "DH001", name: "대한금속" } - Note over Lookup: 필드에 "대한금속" 표시 - - Note over User,Modal: [방식 B: 외부 화면 참조] - User->>Lookup: 거래처 필드 클릭 - Lookup->>Modal: 모달 열림 (screenId=42 화면 로드) - Note over Modal: 별도 POP 화면 렌더링 - Note over Modal: pop-search(검색) + pop-table(목록) 등 배치된 컴포넌트 동작 - User->>Modal: 검색 후 "대한금속" 선택 - Modal->>Lookup: returnMapping 기반으로 값 반환 - Note over Lookup: 필드에 "대한금속" 표시 -``` - -### 예시 5: 컬럼별 읽기/쓰기 분리 동작 - -5개 컬럼이 있는 발주 화면: - -- item_code (read) -> 화면에 표시, 저장 안 함 -- item_name (read, 조인) -> item_info 테이블에서 가져옴, 저장 안 함 -- inbound_qty (readwrite) -> 화면에 표시 + 사용자 수정 + 저장 -- warehouse (write) -> 사용자 입력 + 저장 -- memo (write) -> 사용자 입력 + 저장 - -저장 API 호출 시: `{ inbound_qty: 500, warehouse: "WH01", memo: "긴급" }` 만 전달 -조회 API 호출 시: 5개 컬럼 전부 + 조인된 item_name까지 조회 - ---- - -## 구현 우선순위 - -- Phase 0 (공통 인프라): ColumnBinding, JoinConfig, DataSourceConfig 타입, useDataSource 훅 (CRUD 포함), usePopEvent 훅 (데이터 전달 포함), PopActionConfig 타입 -- Phase 1 (기본 표시): pop-dashboard (4개 서브타입 전부 + 멀티 아이템 컨테이너 + 4개 표시 모드 + 계산식) -- Phase 2 (기본 액션): pop-button, pop-icon -- Phase 3 (데이터 목록): pop-table (테이블형부터, 카드형은 후순위) -- Phase 4 (입력/연동): pop-search, pop-field, pop-lookup -- Phase 5 (고도화): pop-table 카드 템플릿 -- Phase 6 (시스템): pop-system (프로필, 테마, 대시보드 보이기/숨기기 통합) - -### Phase 1 상세 변경 (2026-02-09 토의 결정) - -기존 계획에서 "KPI 카드 우선"이었으나, 토의 결과 **4개 서브타입 전부를 Phase 1에서 구현**으로 변경: -- kpi-card, chart, gauge, stat-card 모두 Phase 1 -- 멀티 아이템 컨테이너 (arrows, auto-slide, grid, scroll) -- 계산식 지원 (formula) -- 드롭다운 기반 쉬운 집계 설정 -- 기존 `frontend/components/pop/dashboard/` 폴더는 Phase 1 완료 후 폐기/삭제 - -### 백엔드 API 현황 (호환성 점검 완료) - -기존 백엔드에 이미 구현되어 있어 새로 만들 필요 없는 API: - -| API | 용도 | 비고 | -|-----|------|------| -| `dataApi.getTableData()` | 동적 테이블 조회 | 페이징, 검색, 정렬, 필터 | -| `dataApi.getJoinedData()` | 2개 테이블 조인 | Entity 조인, 필터링, 중복제거 | -| `entityJoinApi.getTableDataWithJoins()` | Entity 조인 전용 | ID->이름 자동 변환 | -| `dataApi.createRecord/updateRecord/deleteRecord()` | 동적 CRUD | - | -| `dataApi.upsertGroupedRecords()` | 그룹 UPSERT | - | -| `dashboardApi.executeQuery()` | SELECT SQL 직접 실행 | 집계/복합조인용 | -| `dashboardApi.getTableSchema()` | 테이블/컬럼 목록 | 설정 패널 드롭다운용 | - -**백엔드 신규 개발 불필요** - 기존 API만으로 모든 데이터 연동 가능 - -### useDataSource의 API 선택 전략 - -``` -단순 조회 (조인/집계 없음) -> dataApi.getTableData() 또는 entityJoinApi -2개 테이블 조인 -> dataApi.getJoinedData() -3개+ 테이블 조인 또는 집계 -> DataSourceConfig를 SQL로 변환 -> dashboardApi.executeQuery() -CRUD -> dataApi.createRecord/updateRecord/deleteRecord() -``` - -### POP 전용 훅 분리 (2026-02-09 결정) - -데스크탑과의 완전 분리를 위해 POP 전용 훅은 별도 폴더: -- `frontend/hooks/pop/usePopEvent.ts` (POP 전용) -- `frontend/hooks/pop/useDataSource.ts` (POP 전용) - -## 기존 시스템 호환성 검증 결과 (v8.0 추가) - -v8.0에서 추가된 모달 설계 방식에 대해 기존 시스템과의 호환성을 검증한 결과: - -### DB 스키마 (변경 불필요) - -| 테이블 | 현재 구조 | 호환성 | -|--------|-----------|--------| -| screen_layouts_v2 | layout_data JSONB + screen_id + company_code + layer_id | modalConfig를 컴포넌트 overrides에 포함하면 됨 | -| screen_layouts_pop | 동일 구조 (POP 전용) | 모달 화면도 별도 screen_id로 저장 가능 | - -- layout_data가 JSONB 타입이므로 어떤 JSON 구조든 저장 가능 -- 모달 화면을 별도 screen_id로 만들어도 기존 UNIQUE(screen_id, company_code, layer_id) 제약조건과 충돌 없음 -- DB 마이그레이션 불필요 - -### 백엔드 API (변경 불필요) - -| API | 엔드포인트 | 호환성 | -|-----|-----------|--------| -| POP 레이아웃 저장 | POST /api/screen-management/screens/:screenId/layout-pop | 모달 화면도 동일 API로 저장 | -| POP 레이아웃 조회 | GET /api/screen-management/screens/:screenId/layout-pop | 모달 화면도 동일 API로 조회 | -| 연결 모달 감지 | detectLinkedModals(screenId) | 화면 간 참조 관계 추적에 활용 | - -### 프론트엔드 (참고 패턴 존재) - -| 기존 기능 | 위치 | 활용 방안 | -|-----------|------|-----------| -| TabsWidget screenId 참조 | frontend/components/screen/widgets/TabsWidget.tsx | 같은 패턴으로 모달에서 외부 화면 로드 | -| TabsConfigPanel | frontend/components/screen/config-panels/TabsConfigPanel.tsx | pop-lookup 설정 패널의 모달 화면 선택 UI 참조 | -| ScreenDesigner 탭 내부 컴포넌트 | frontend/components/screen/ScreenDesigner.tsx | 모달 내부 컴포넌트 편집 패턴 참조 | - -### 결론 - -- DB 마이그레이션: 불필요 -- 백엔드 변경: 불필요 -- 프론트엔드: pop-lookup 컴포넌트 구현 시 기존 TabsWidget의 screenId 참조 패턴을 그대로 차용 -- 새로운 API: 불필요 (기존 saveLayoutPop/getLayoutPop로 충분) - -## 참고 파일 - -- 레지스트리: `frontend/lib/registry/PopComponentRegistry.ts` -- 기존 텍스트 컴포넌트: `frontend/lib/registry/pop-components/pop-text.tsx` -- 공통 스타일 타입: `frontend/lib/registry/pop-components/types.ts` -- POP 타입 정의: `frontend/components/pop/designer/types/pop-layout.ts` -- 기존 스펙 (v4): `popdocs/components-spec.md` -- 탭 위젯 (모달 참조 패턴): `frontend/components/screen/widgets/TabsWidget.tsx` -- POP 레이아웃 API: `frontend/lib/api/screen.ts` (saveLayoutPop, getLayoutPop) -- 백엔드 화면관리: `backend-node/src/controllers/screenManagementController.ts` diff --git a/STATUS.md b/STATUS.md deleted file mode 100644 index 09b8da12..00000000 --- a/STATUS.md +++ /dev/null @@ -1,46 +0,0 @@ -# 프로젝트 상태 추적 - -> **최종 업데이트**: 2026-02-11 - ---- - -## 현재 진행 중 - -### pop-dashboard 스타일 정리 -**상태**: 코딩 완료, 브라우저 확인 대기 -**계획서**: [popdocs/PLAN.md](./popdocs/PLAN.md) -**내용**: 글자 크기 커스텀 제거 + 라벨 정렬만 유지 + stale closure 수정 - ---- - -## 다음 작업 - -| 순서 | 작업 | 상태 | -|------|------|------| -| 1 | pop-card-list 입력 필드/계산 필드 구조 개편 (PLAN.MD 참고) | [ ] 코딩 대기 | -| 2 | pop-card-list 담기 버튼 독립화 (보류) | [ ] 대기 | -| 3 | pop-card-list 반응형 표시 런타임 적용 | [ ] 대기 | - ---- - -## 완료된 작업 (최근) - -| 날짜 | 작업 | 비고 | -|------|------|------| -| 2026-02-11 | 대시보드 스타일 정리 | FONT_SIZE_PX/글자 크기 Select 삭제, ItemStyleConfig -> labelAlign만, stale closure 수정 | -| 2026-02-10 | 디자이너 캔버스 UX 개선 | 헤더 제거, 실제 데이터 렌더링, 컴포넌트 목록 | -| 2026-02-10 | 차트/게이지/네비게이션/정렬 디자인 개선 | CartesianGrid, abbreviateNumber, 오버레이 화살표/인디케이터 | -| 2026-02-10 | 대시보드 4가지 아이템 모드 완성 | groupBy UI, xAxisColumn, 통계카드 카테고리, 필터 버그 수정 | -| 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 | -| 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent | -| 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 | - ---- - -## 알려진 이슈 - -| # | 이슈 | 심각도 | 상태 | -|---|------|--------|------| -| 1 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 | -| 2 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 | -| 3 | 기존 저장 데이터의 `itemStyle.align`이 `labelAlign`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 | diff --git a/approval-company7-report.txt b/approval-company7-report.txt deleted file mode 100644 index 57760435..00000000 --- a/approval-company7-report.txt +++ /dev/null @@ -1,33 +0,0 @@ - -=== Step 1: 로그인 (topseal_admin) === - 현재 URL: http://localhost:9771/screens/138 - 스크린샷: 01-after-login.png - OK: 로그인 완료 - -=== Step 2: 발주관리 화면 이동 === - 스크린샷: 02-po-screen.png - OK: 발주관리 화면 로드 - -=== Step 3: 그리드 컬럼 및 데이터 확인 === - 컬럼 헤더 (전체): ["결재상태","발주번호","품목코드","품목명","규격","발주수량","출하수량","단위","구분","유형","재질","규격","품명"] - 첫 번째 컬럼: "결재상태" - 결재상태(한글) 표시됨 - 데이터 행 수: 11 - 데이터 있음 - 첫 번째 컬럼 값(샘플): ["","","","",""] - 발주번호 형식 데이터: ["PO-2026-0001","PO-2026-0001","PO-2026-0001","PO-2026-0045","PO-2026-0045"] - 스크린샷: 03-grid-detail.png - OK: 그리드 상세 스크린샷 저장 - -=== Step 4: 결재 요청 버튼 확인 === - OK: '결재 요청' 파란색 버튼 확인됨 - 스크린샷: 04-approval-button.png - -=== Step 5: 행 선택 후 결재 요청 === - OK: 행 선택 완료 - 스크린샷: 05-approval-modal.png - OK: 결재 모달 열림 - 스크린샷: 06-approver-search-results.png - 결재자 검색 결과: 8명 - 결재자 목록: ["상신결재","다단 결재순차적으로 결재","동시 결재모든 결재자 동시 진행","TEST(Kim1542)김동현","김혜인(qwer0578)배달집행부 / 차장","김지수(area09)배달집행부 / 대리","김한길(qoznd123)배달집행부 / 과장","김하세(kaoe123)배달집행부 / 사원"] - 스크린샷: 07-final.png \ No newline at end of file diff --git a/approval-test-report.txt b/approval-test-report.txt deleted file mode 100644 index 4a2e6386..00000000 --- a/approval-test-report.txt +++ /dev/null @@ -1,29 +0,0 @@ - -=== Step 1: 로그인 === - 스크린샷: 01-login-page.png - 스크린샷: 02-after-login.png - OK: 로그인 완료, 대시보드 로드 - -=== Step 2: 구매관리 → 발주관리 메뉴 이동 === - INFO: 메뉴에서 발주관리 미발견, 직접 URL로 이동 - 메뉴 목록: ["관리자 메뉴로 전환","회사 선택","관리자해외영업부"] - 스크린샷: 04-po-screen-loaded.png - OK: /screen/COMPANY_7_064 직접 이동 완료 - -=== Step 3: 그리드 컬럼 확인 === - 스크린샷: 05-grid-columns.png - 컬럼 목록: ["approval_status","발주번호","품목코드","품목명","규격","발주수량","출하","단위","구분","유형","재질","규격","품명"] - FAIL: '결재상태' 컬럼 없음 - 결재상태 값: 데이터 없음 또는 해당 값 없음 - -=== Step 4: 행 선택 및 결재 요청 버튼 클릭 === - 스크린샷: 06-row-selected.png - OK: 첫 번째 행 선택 - 스크린샷: 07-approval-modal-opened.png - OK: 결재 모달 열림 - -=== Step 5: 결재자 검색 테스트 === - 스크린샷: 08-approver-search-results.png - 검색 결과 수: 12명 - 결재자 목록: ["상신결재","템플릿","다단 결재순차적으로 결재","동시 결재모든 결재자 동시 진행","김동열(drkim)-","김아름(qwe123)생산부 / 차장","TEST(Kim1542)김동현","김혜인(qwer0578)배달집행부 / 차장","김욱동(dnrehd0171)-","김지수(area09)배달집행부 / 대리"] - 스크린샷: 09-final-state.png \ No newline at end of file diff --git a/backend-node/.env.shared b/backend-node/.env.shared index 3b546ed9..5cd2673f 100644 --- a/backend-node/.env.shared +++ b/backend-node/.env.shared @@ -3,25 +3,26 @@ # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # # ⚠️ 주의: 이 파일은 Git에 커밋됩니다! -# 팀원들이 동일한 API 키를 사용합니다. +# 실제 API 키는 .env 파일에 설정하세요. +# 여기에는 키 형식 예시만 기록합니다. # # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 한국은행 환율 API 키 # 발급: https://www.bok.or.kr/portal/openapi/OpenApiGuide.do -BOK_API_KEY=OXIGPQXH68NUKVKL5KT9 +BOK_API_KEY=your_bok_api_key_here # 기상청 API Hub 키 # 발급: https://apihub.kma.go.kr/ -KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA +KMA_API_KEY=your_kma_api_key_here # ITS 국가교통정보센터 API 키 # 발급: https://www.its.go.kr/ -ITS_API_KEY=d6b9befec3114d648284674b8fddcc32 +ITS_API_KEY=your_its_api_key_here # 한국도로공사 OpenOASIS API 키 # 발급: https://data.ex.co.kr/ (OpenOASIS 신청) -EXWAY_API_KEY=7820214492 +EXWAY_API_KEY=your_exway_api_key_here # ExchangeRate API 키 (백업용, 선택사항) # 발급: https://www.exchangerate-api.com/ diff --git a/backend-node/API_연동_가이드.md b/backend-node/API_연동_가이드.md index 0af08e43..d5771c03 100644 --- a/backend-node/API_연동_가이드.md +++ b/backend-node/API_연동_가이드.md @@ -6,12 +6,12 @@ ### ✅ 작동 중인 API 1. **기상청 특보 API** (완벽 작동!) - - API 키: `ogdXr2e9T4iHV69nvV-IwA` + - API 키: `${KMA_API_KEY}` - 상태: ✅ 14건 실시간 특보 수신 중 - 제공 데이터: 대설/강풍/한파/태풍/폭염 특보 2. **한국은행 환율 API** (완벽 작동!) - - API 키: `OXIGPQXH68NUKVKL5KT9` + - API 키: `${BOK_API_KEY}` - 상태: ✅ 환율 위젯 작동 중 ### ⚠️ 더미 데이터 사용 중 @@ -59,7 +59,7 @@ docker restart pms-backend-mac ### 발급된 키 ``` -EXWAY_API_KEY=7820214492 +EXWAY_API_KEY=${EXWAY_API_KEY} ``` ### 문제 상황 diff --git a/backend-node/API_키_정리.md b/backend-node/API_키_정리.md index 04d8f245..f4de8d2a 100644 --- a/backend-node/API_키_정리.md +++ b/backend-node/API_키_정리.md @@ -4,13 +4,13 @@ ## ✅ 완벽 작동 중 ### 1. 기상청 API Hub -- **API 키**: `ogdXr2e9T4iHV69nvV-IwA` +- **API 키**: `${KMA_API_KEY}` - **상태**: ✅ 14건 실시간 특보 수신 중 - **제공 데이터**: 대설/강풍/한파/태풍/폭염 특보 - **코드 위치**: `backend-node/src/services/riskAlertService.ts` ### 2. 한국은행 환율 API -- **API 키**: `OXIGPQXH68NUKVKL5KT9` +- **API 키**: `${BOK_API_KEY}` - **상태**: ✅ 환율 위젯 작동 중 - **제공 데이터**: USD/EUR/JPY/CNY 환율 @@ -19,7 +19,7 @@ ## ⚠️ 연동 대기 중 ### 3. 한국도로공사 OpenOASIS API -- **API 키**: `7820214492` +- **API 키**: `${EXWAY_API_KEY}` - **상태**: ❌ 엔드포인트 URL 불명 - **문제**: - 발급 이메일에 사용법 없음 @@ -34,7 +34,7 @@ 시스템 장애: 070-8656-8771 문의 내용: -"OpenOASIS API 인증키(7820214492)를 발급받았는데 +"OpenOASIS API 인증키(${EXWAY_API_KEY})를 발급받았는데 사용 방법과 엔드포인트 URL을 알려주세요. - 돌발상황정보 API - 교통사고 정보 @@ -42,7 +42,7 @@ ``` ### 4. 국토교통부 ITS API -- **API 키**: `d6b9befec3114d648284674b8fddcc32` +- **API 키**: `${ITS_API_KEY}` - **상태**: ❌ 엔드포인트 URL 불명 - **승인 API**: - 교통소통정보 @@ -63,7 +63,7 @@ 이메일: its@ex.co.kr 문의 내용: -"ITS API 인증키(d6b9befec3114d648284674b8fddcc32)를 +"ITS API 인증키(${ITS_API_KEY})를 발급받았는데 매뉴얼에 엔드포인트 URL이 없습니다. 돌발상황정보 API의 정확한 URL과 파라미터를 알려주세요." @@ -88,8 +88,8 @@ ### 연동 방법 ```bash # .env 파일에 추가 -ITS_API_KEY=d6b9befec3114d648284674b8fddcc32 -EXWAY_API_KEY=7820214492 +ITS_API_KEY=${ITS_API_KEY} +EXWAY_API_KEY=${EXWAY_API_KEY} # 백엔드 재시작 docker restart pms-backend-mac diff --git a/backend-node/README.md b/backend-node/README.md index 84bff2a1..8e3f5015 100644 --- a/backend-node/README.md +++ b/backend-node/README.md @@ -48,7 +48,7 @@ npm install `.env` 파일을 생성하고 다음 내용을 추가하세요: ```env -DATABASE_URL="postgresql://postgres:ph0909!!@39.117.244.52:11132/plm" +DATABASE_URL="postgresql://postgres:YOUR_PASSWORD@YOUR_HOST:YOUR_PORT/YOUR_DB" JWT_SECRET="your-super-secret-jwt-key-change-in-production" JWT_EXPIRES_IN="24h" PORT=8080 diff --git a/backend-node/README_API_SETUP.md b/backend-node/README_API_SETUP.md index 6f4a930d..6e460d44 100644 --- a/backend-node/README_API_SETUP.md +++ b/backend-node/README_API_SETUP.md @@ -19,19 +19,19 @@ cp .env.shared .env ### ✅ 한국은행 환율 API - 용도: 환율 정보 조회 -- 키: `OXIGPQXH68NUKVKL5KT9` +- 키: `${BOK_API_KEY}` ### ✅ 기상청 API Hub - 용도: 날씨특보, 기상정보 -- 키: `ogdXr2e9T4iHV69nvV-IwA` +- 키: `${KMA_API_KEY}` ### ✅ ITS 국가교통정보센터 - 용도: 교통사고, 도로공사 정보 -- 키: `d6b9befec3114d648284674b8fddcc32` +- 키: `${ITS_API_KEY}` ### ✅ 한국도로공사 OpenOASIS - 용도: 고속도로 교통정보 -- 키: `7820214492` +- 키: `${EXWAY_API_KEY}` --- diff --git a/backend-node/scripts/add-button-webtype.js b/backend-node/scripts/add-button-webtype.js deleted file mode 100644 index 2fd68221..00000000 --- a/backend-node/scripts/add-button-webtype.js +++ /dev/null @@ -1,52 +0,0 @@ -const { PrismaClient } = require("@prisma/client"); - -const prisma = new PrismaClient(); - -async function addButtonWebType() { - try { - console.log("🔍 버튼 웹타입 확인 중..."); - - // 기존 button 웹타입 확인 - const existingButton = await prisma.web_type_standards.findUnique({ - where: { web_type: "button" }, - }); - - if (existingButton) { - console.log("✅ 버튼 웹타입이 이미 존재합니다."); - console.log("📄 기존 설정:", JSON.stringify(existingButton, null, 2)); - return; - } - - console.log("➕ 버튼 웹타입 추가 중..."); - - // 버튼 웹타입 추가 - const buttonWebType = await prisma.web_type_standards.create({ - data: { - web_type: "button", - type_name: "버튼", - type_name_eng: "Button", - description: "클릭 가능한 버튼 컴포넌트", - category: "action", - component_name: "ButtonWidget", - config_panel: "ButtonConfigPanel", - default_config: { - actionType: "custom", - variant: "default", - }, - sort_order: 100, - is_active: "Y", - created_by: "system", - updated_by: "system", - }, - }); - - console.log("✅ 버튼 웹타입이 성공적으로 추가되었습니다!"); - console.log("📄 추가된 설정:", JSON.stringify(buttonWebType, null, 2)); - } catch (error) { - console.error("❌ 버튼 웹타입 추가 실패:", error); - } finally { - await prisma.$disconnect(); - } -} - -addButtonWebType(); diff --git a/backend-node/scripts/add-data-mapping-column.js b/backend-node/scripts/add-data-mapping-column.js deleted file mode 100644 index cd7ee154..00000000 --- a/backend-node/scripts/add-data-mapping-column.js +++ /dev/null @@ -1,34 +0,0 @@ -const { PrismaClient } = require("@prisma/client"); - -const prisma = new PrismaClient(); - -async function addDataMappingColumn() { - try { - console.log( - "🔄 external_call_configs 테이블에 data_mapping_config 컬럼 추가 중..." - ); - - // data_mapping_config JSONB 컬럼 추가 - await prisma.$executeRaw` - ALTER TABLE external_call_configs - ADD COLUMN IF NOT EXISTS data_mapping_config JSONB - `; - - console.log("✅ data_mapping_config 컬럼이 성공적으로 추가되었습니다."); - - // 기존 레코드에 기본값 설정 - await prisma.$executeRaw` - UPDATE external_call_configs - SET data_mapping_config = '{"direction": "none"}'::jsonb - WHERE data_mapping_config IS NULL - `; - - console.log("✅ 기존 레코드에 기본값이 설정되었습니다."); - } catch (error) { - console.error("❌ 컬럼 추가 실패:", error); - } finally { - await prisma.$disconnect(); - } -} - -addDataMappingColumn(); diff --git a/backend-node/scripts/add-external-db-connection.ts b/backend-node/scripts/add-external-db-connection.ts index b595168a..67788f67 100644 --- a/backend-node/scripts/add-external-db-connection.ts +++ b/backend-node/scripts/add-external-db-connection.ts @@ -27,11 +27,11 @@ async function addExternalDbConnection() { name: "운영_외부_PostgreSQL", description: "운영용 외부 PostgreSQL 데이터베이스", dbType: "postgresql", - host: "39.117.244.52", - port: 11132, - databaseName: "plm", - username: "postgres", - password: "ph0909!!", // 이 값은 암호화되어 저장됩니다 + host: process.env.EXT_DB_HOST || "localhost", + port: parseInt(process.env.EXT_DB_PORT || "5432"), + databaseName: process.env.EXT_DB_NAME || "vexplor_dev", + username: process.env.EXT_DB_USER || "postgres", + password: process.env.EXT_DB_PASSWORD || "", // 환경변수로 전달 sslEnabled: false, isActive: true, }, diff --git a/backend-node/scripts/add-missing-columns.js b/backend-node/scripts/add-missing-columns.js deleted file mode 100644 index 4b21e702..00000000 --- a/backend-node/scripts/add-missing-columns.js +++ /dev/null @@ -1,105 +0,0 @@ -const { PrismaClient } = require("@prisma/client"); - -const prisma = new PrismaClient(); - -async function addMissingColumns() { - try { - console.log("🔄 누락된 컬럼들을 screen_layouts 테이블에 추가 중..."); - - // layout_type 컬럼 추가 - try { - await prisma.$executeRaw` - ALTER TABLE screen_layouts - ADD COLUMN IF NOT EXISTS layout_type VARCHAR(50); - `; - console.log("✅ layout_type 컬럼 추가 완료"); - } catch (error) { - console.log( - "ℹ️ layout_type 컬럼이 이미 존재하거나 추가 중 오류:", - error.message - ); - } - - // layout_config 컬럼 추가 - try { - await prisma.$executeRaw` - ALTER TABLE screen_layouts - ADD COLUMN IF NOT EXISTS layout_config JSONB; - `; - console.log("✅ layout_config 컬럼 추가 완료"); - } catch (error) { - console.log( - "ℹ️ layout_config 컬럼이 이미 존재하거나 추가 중 오류:", - error.message - ); - } - - // zones_config 컬럼 추가 - try { - await prisma.$executeRaw` - ALTER TABLE screen_layouts - ADD COLUMN IF NOT EXISTS zones_config JSONB; - `; - console.log("✅ zones_config 컬럼 추가 완료"); - } catch (error) { - console.log( - "ℹ️ zones_config 컬럼이 이미 존재하거나 추가 중 오류:", - error.message - ); - } - - // zone_id 컬럼 추가 - try { - await prisma.$executeRaw` - ALTER TABLE screen_layouts - ADD COLUMN IF NOT EXISTS zone_id VARCHAR(100); - `; - console.log("✅ zone_id 컬럼 추가 완료"); - } catch (error) { - console.log( - "ℹ️ zone_id 컬럼이 이미 존재하거나 추가 중 오류:", - error.message - ); - } - - // 인덱스 생성 (성능 향상) - try { - await prisma.$executeRaw` - CREATE INDEX IF NOT EXISTS idx_screen_layouts_layout_type - ON screen_layouts(layout_type); - `; - console.log("✅ layout_type 인덱스 생성 완료"); - } catch (error) { - console.log("ℹ️ layout_type 인덱스 생성 중 오류:", error.message); - } - - try { - await prisma.$executeRaw` - CREATE INDEX IF NOT EXISTS idx_screen_layouts_zone_id - ON screen_layouts(zone_id); - `; - console.log("✅ zone_id 인덱스 생성 완료"); - } catch (error) { - console.log("ℹ️ zone_id 인덱스 생성 중 오류:", error.message); - } - - // 최종 테이블 구조 확인 - const columns = await prisma.$queryRaw` - SELECT column_name, data_type, is_nullable, column_default - FROM information_schema.columns - WHERE table_name = 'screen_layouts' - ORDER BY ordinal_position - `; - - console.log("\n📋 screen_layouts 테이블 최종 구조:"); - console.table(columns); - - console.log("\n🎉 모든 누락된 컬럼 추가 작업이 완료되었습니다!"); - } catch (error) { - console.error("❌ 컬럼 추가 중 오류 발생:", error); - } finally { - await prisma.$disconnect(); - } -} - -addMissingColumns(); diff --git a/backend-node/scripts/btn-bulk-update-company7.ts b/backend-node/scripts/btn-bulk-update-company7.ts deleted file mode 100644 index ee757a0c..00000000 --- a/backend-node/scripts/btn-bulk-update-company7.ts +++ /dev/null @@ -1,318 +0,0 @@ -/** - * 탑씰(company_7) 버튼 스타일 일괄 변경 스크립트 - * - * 사용법: - * npx ts-node scripts/btn-bulk-update-company7.ts --test # 1건만 테스트 (ROLLBACK) - * npx ts-node scripts/btn-bulk-update-company7.ts --run # 전체 실행 (COMMIT) - * npx ts-node scripts/btn-bulk-update-company7.ts --backup # 백업 테이블만 생성 - * npx ts-node scripts/btn-bulk-update-company7.ts --restore # 백업에서 원복 - */ - -import { Pool } from "pg"; - -// ── 배포 DB 연결 ── -const pool = new Pool({ - connectionString: - "postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor", -}); - -const COMPANY_CODE = "COMPANY_7"; -const BACKUP_TABLE = "screen_layouts_v2_backup_20260313"; - -// ── 액션별 기본 아이콘 매핑 (frontend/lib/button-icon-map.tsx 기준) ── -const actionIconMap: Record = { - save: "Check", - delete: "Trash2", - edit: "Pencil", - navigate: "ArrowRight", - modal: "Maximize2", - transferData: "SendHorizontal", - excel_download: "Download", - excel_upload: "Upload", - quickInsert: "Zap", - control: "Settings", - barcode_scan: "ScanLine", - operation_control: "Truck", - event: "Send", - copy: "Copy", -}; -const FALLBACK_ICON = "SquareMousePointer"; - -function getIconForAction(actionType?: string): string { - if (actionType && actionIconMap[actionType]) { - return actionIconMap[actionType]; - } - return FALLBACK_ICON; -} - -// ── 버튼 컴포넌트인지 판별 (최상위 + 탭 내부 둘 다 지원) ── -function isTopLevelButton(comp: any): boolean { - return ( - comp.url?.includes("v2-button-primary") || - comp.overrides?.type === "v2-button-primary" - ); -} - -function isTabChildButton(comp: any): boolean { - return comp.componentType === "v2-button-primary"; -} - -function isButtonComponent(comp: any): boolean { - return isTopLevelButton(comp) || isTabChildButton(comp); -} - -// ── 탭 위젯인지 판별 ── -function isTabsWidget(comp: any): boolean { - return ( - comp.url?.includes("v2-tabs-widget") || - comp.overrides?.type === "v2-tabs-widget" - ); -} - -// ── 버튼 스타일 변경 (최상위 버튼용: overrides 사용) ── -function applyButtonStyle(config: any, actionType: string | undefined) { - const iconName = getIconForAction(actionType); - - config.displayMode = "icon-text"; - - config.icon = { - name: iconName, - type: "lucide", - size: "보통", - ...(config.icon?.color ? { color: config.icon.color } : {}), - }; - - config.iconTextPosition = "right"; - config.iconGap = 6; - - if (!config.style) config.style = {}; - delete config.style.width; // 레거시 하드코딩 너비 제거 (size.width만 사용) - config.style.borderRadius = "8px"; - config.style.labelColor = "#FFFFFF"; - config.style.fontSize = "12px"; - config.style.fontWeight = "normal"; - config.style.labelTextAlign = "left"; - - if (actionType === "delete") { - config.style.backgroundColor = "#F04544"; - } else if (actionType === "excel_upload" || actionType === "excel_download") { - config.style.backgroundColor = "#212121"; - } else { - config.style.backgroundColor = "#3B83F6"; - } -} - -function updateButtonStyle(comp: any): boolean { - if (isTopLevelButton(comp)) { - const overrides = comp.overrides || {}; - const actionType = overrides.action?.type; - - if (!comp.size) comp.size = {}; - comp.size.height = 40; - - applyButtonStyle(overrides, actionType); - comp.overrides = overrides; - return true; - } - - if (isTabChildButton(comp)) { - const config = comp.componentConfig || {}; - const actionType = config.action?.type; - - if (!comp.size) comp.size = {}; - comp.size.height = 40; - - applyButtonStyle(config, actionType); - comp.componentConfig = config; - - // 탭 내부 버튼은 렌더러가 comp.style (최상위)에서 스타일을 읽음 - if (!comp.style) comp.style = {}; - comp.style.borderRadius = "8px"; - comp.style.labelColor = "#FFFFFF"; - comp.style.fontSize = "12px"; - comp.style.fontWeight = "normal"; - comp.style.labelTextAlign = "left"; - comp.style.backgroundColor = config.style.backgroundColor; - - return true; - } - - return false; -} - -// ── 백업 테이블 생성 ── -async function createBackup() { - console.log(`\n=== 백업 테이블 생성: ${BACKUP_TABLE} ===`); - - const exists = await pool.query( - `SELECT to_regclass($1) AS tbl`, - [BACKUP_TABLE], - ); - if (exists.rows[0].tbl) { - console.log(`백업 테이블이 이미 존재합니다: ${BACKUP_TABLE}`); - const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`); - console.log(`기존 백업 레코드 수: ${count.rows[0].count}`); - return; - } - - await pool.query( - `CREATE TABLE ${BACKUP_TABLE} AS - SELECT * FROM screen_layouts_v2 - WHERE company_code = $1`, - [COMPANY_CODE], - ); - - const count = await pool.query(`SELECT COUNT(*) FROM ${BACKUP_TABLE}`); - console.log(`백업 완료. 레코드 수: ${count.rows[0].count}`); -} - -// ── 백업에서 원복 ── -async function restoreFromBackup() { - console.log(`\n=== 백업에서 원복: ${BACKUP_TABLE} ===`); - - const result = await pool.query( - `UPDATE screen_layouts_v2 AS target - SET layout_data = backup.layout_data, - updated_at = backup.updated_at - FROM ${BACKUP_TABLE} AS backup - WHERE target.screen_id = backup.screen_id - AND target.company_code = backup.company_code - AND target.layer_id = backup.layer_id`, - ); - console.log(`원복 완료. 변경된 레코드 수: ${result.rowCount}`); -} - -// ── 메인: 버튼 일괄 변경 ── -async function updateButtons(testMode: boolean) { - const modeLabel = testMode ? "테스트 (1건, ROLLBACK)" : "전체 실행 (COMMIT)"; - console.log(`\n=== 버튼 일괄 변경 시작 [${modeLabel}] ===`); - - // company_7 레코드 조회 - const rows = await pool.query( - `SELECT screen_id, layer_id, company_code, layout_data - FROM screen_layouts_v2 - WHERE company_code = $1 - ORDER BY screen_id, layer_id`, - [COMPANY_CODE], - ); - console.log(`대상 레코드 수: ${rows.rowCount}`); - - if (!rows.rowCount) { - console.log("변경할 레코드가 없습니다."); - return; - } - - const client = await pool.connect(); - try { - await client.query("BEGIN"); - - let totalUpdated = 0; - let totalButtons = 0; - const targetRows = testMode ? [rows.rows[0]] : rows.rows; - - for (const row of targetRows) { - const layoutData = row.layout_data; - if (!layoutData?.components || !Array.isArray(layoutData.components)) { - continue; - } - - let buttonsInRow = 0; - for (const comp of layoutData.components) { - // 최상위 버튼 처리 - if (updateButtonStyle(comp)) { - buttonsInRow++; - } - - // 탭 위젯 내부 버튼 처리 - if (isTabsWidget(comp)) { - const tabs = comp.overrides?.tabs || []; - for (const tab of tabs) { - const tabComps = tab.components || []; - for (const tabComp of tabComps) { - if (updateButtonStyle(tabComp)) { - buttonsInRow++; - } - } - } - } - } - - if (buttonsInRow > 0) { - await client.query( - `UPDATE screen_layouts_v2 - SET layout_data = $1, updated_at = NOW() - WHERE screen_id = $2 AND company_code = $3 AND layer_id = $4`, - [JSON.stringify(layoutData), row.screen_id, row.company_code, row.layer_id], - ); - totalUpdated++; - totalButtons += buttonsInRow; - - console.log( - ` screen_id=${row.screen_id}, layer_id=${row.layer_id} → 버튼 ${buttonsInRow}개 변경`, - ); - - // 테스트 모드: 변경 전후 비교를 위해 첫 번째 버튼 출력 - if (testMode) { - const sampleBtn = layoutData.components.find(isButtonComponent); - if (sampleBtn) { - console.log("\n--- 변경 후 샘플 버튼 ---"); - console.log(JSON.stringify(sampleBtn, null, 2)); - } - } - } - } - - console.log(`\n--- 결과 ---`); - console.log(`변경된 레코드: ${totalUpdated}개`); - console.log(`변경된 버튼: ${totalButtons}개`); - - if (testMode) { - await client.query("ROLLBACK"); - console.log("\n[테스트 모드] ROLLBACK 완료. 실제 DB 변경 없음."); - } else { - await client.query("COMMIT"); - console.log("\nCOMMIT 완료."); - } - } catch (err) { - await client.query("ROLLBACK"); - console.error("\n에러 발생. ROLLBACK 완료.", err); - throw err; - } finally { - client.release(); - } -} - -// ── CLI 진입점 ── -async function main() { - const arg = process.argv[2]; - - if (!arg || !["--test", "--run", "--backup", "--restore"].includes(arg)) { - console.log("사용법:"); - console.log(" --test : 1건 테스트 (ROLLBACK, DB 변경 없음)"); - console.log(" --run : 전체 실행 (COMMIT)"); - console.log(" --backup : 백업 테이블 생성"); - console.log(" --restore : 백업에서 원복"); - process.exit(1); - } - - try { - if (arg === "--backup") { - await createBackup(); - } else if (arg === "--restore") { - await restoreFromBackup(); - } else if (arg === "--test") { - await createBackup(); - await updateButtons(true); - } else if (arg === "--run") { - await createBackup(); - await updateButtons(false); - } - } catch (err) { - console.error("스크립트 실행 실패:", err); - process.exit(1); - } finally { - await pool.end(); - } -} - -main(); diff --git a/backend-node/scripts/check-dashboard-structure.js b/backend-node/scripts/check-dashboard-structure.js deleted file mode 100644 index d7b9ab1d..00000000 --- a/backend-node/scripts/check-dashboard-structure.js +++ /dev/null @@ -1,75 +0,0 @@ -/** - * dashboards 테이블 구조 확인 스크립트 - */ - -const { Pool } = require('pg'); - -const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm'; - -const pool = new Pool({ - connectionString: databaseUrl, -}); - -async function checkDashboardStructure() { - const client = await pool.connect(); - - try { - console.log('🔍 dashboards 테이블 구조 확인 중...\n'); - - // 컬럼 정보 조회 - const columns = await client.query(` - SELECT - column_name, - data_type, - is_nullable, - column_default - FROM information_schema.columns - WHERE table_name = 'dashboards' - ORDER BY ordinal_position - `); - - console.log('📋 dashboards 테이블 컬럼:\n'); - columns.rows.forEach((col, index) => { - console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`); - }); - - // 샘플 데이터 조회 - console.log('\n📊 샘플 데이터 (첫 1개):'); - const sample = await client.query(` - SELECT * FROM dashboards LIMIT 1 - `); - - if (sample.rows.length > 0) { - console.log(JSON.stringify(sample.rows[0], null, 2)); - } else { - console.log('❌ 데이터가 없습니다.'); - } - - // dashboard_elements 테이블도 확인 - console.log('\n🔍 dashboard_elements 테이블 구조 확인 중...\n'); - - const elemColumns = await client.query(` - SELECT - column_name, - data_type, - is_nullable - FROM information_schema.columns - WHERE table_name = 'dashboard_elements' - ORDER BY ordinal_position - `); - - console.log('📋 dashboard_elements 테이블 컬럼:\n'); - elemColumns.rows.forEach((col, index) => { - console.log(`${index + 1}. ${col.column_name} (${col.data_type}) - Nullable: ${col.is_nullable}`); - }); - - } catch (error) { - console.error('❌ 오류 발생:', error.message); - } finally { - client.release(); - await pool.end(); - } -} - -checkDashboardStructure(); - diff --git a/backend-node/scripts/check-tables.js b/backend-node/scripts/check-tables.js deleted file mode 100644 index 68f9f687..00000000 --- a/backend-node/scripts/check-tables.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * 데이터베이스 테이블 확인 스크립트 - */ - -const { Pool } = require('pg'); - -const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm'; - -const pool = new Pool({ - connectionString: databaseUrl, -}); - -async function checkTables() { - const client = await pool.connect(); - - try { - console.log('🔍 데이터베이스 테이블 확인 중...\n'); - - // 테이블 목록 조회 - const result = await client.query(` - SELECT table_name - FROM information_schema.tables - WHERE table_schema = 'public' - ORDER BY table_name - `); - - console.log(`📊 총 ${result.rows.length}개의 테이블 발견:\n`); - result.rows.forEach((row, index) => { - console.log(`${index + 1}. ${row.table_name}`); - }); - - // dashboard 관련 테이블 검색 - console.log('\n🔎 dashboard 관련 테이블:'); - const dashboardTables = result.rows.filter(row => - row.table_name.toLowerCase().includes('dashboard') - ); - - if (dashboardTables.length === 0) { - console.log('❌ dashboard 관련 테이블을 찾을 수 없습니다.'); - } else { - dashboardTables.forEach(row => { - console.log(`✅ ${row.table_name}`); - }); - } - - } catch (error) { - console.error('❌ 오류 발생:', error.message); - } finally { - client.release(); - await pool.end(); - } -} - -checkTables(); - diff --git a/backend-node/scripts/create-component-table.js b/backend-node/scripts/create-component-table.js deleted file mode 100644 index e40dfbfa..00000000 --- a/backend-node/scripts/create-component-table.js +++ /dev/null @@ -1,74 +0,0 @@ -const { PrismaClient } = require("@prisma/client"); - -const prisma = new PrismaClient(); - -async function createComponentTable() { - try { - console.log("🔧 component_standards 테이블 생성 중..."); - - // 테이블 생성 SQL - await prisma.$executeRaw` - CREATE TABLE IF NOT EXISTS component_standards ( - component_code VARCHAR(50) PRIMARY KEY, - component_name VARCHAR(100) NOT NULL, - component_name_eng VARCHAR(100), - description TEXT, - category VARCHAR(50) NOT NULL, - icon_name VARCHAR(50), - default_size JSON, - component_config JSON NOT NULL, - preview_image VARCHAR(255), - sort_order INTEGER DEFAULT 0, - is_active CHAR(1) DEFAULT 'Y', - is_public CHAR(1) DEFAULT 'Y', - company_code VARCHAR(50) NOT NULL, - created_date TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP, - created_by VARCHAR(50), - updated_date TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP, - updated_by VARCHAR(50) - ) - `; - - console.log("✅ component_standards 테이블 생성 완료"); - - // 인덱스 생성 - await prisma.$executeRaw` - CREATE INDEX IF NOT EXISTS idx_component_standards_category - ON component_standards (category) - `; - - await prisma.$executeRaw` - CREATE INDEX IF NOT EXISTS idx_component_standards_company - ON component_standards (company_code) - `; - - console.log("✅ 인덱스 생성 완료"); - - // 테이블 코멘트 추가 - await prisma.$executeRaw` - COMMENT ON TABLE component_standards IS 'UI 컴포넌트 표준 정보를 저장하는 테이블' - `; - - console.log("✅ 테이블 코멘트 추가 완료"); - } catch (error) { - console.error("❌ 테이블 생성 실패:", error); - throw error; - } finally { - await prisma.$disconnect(); - } -} - -// 실행 -if (require.main === module) { - createComponentTable() - .then(() => { - console.log("🎉 테이블 생성 완료!"); - process.exit(0); - }) - .catch((error) => { - console.error("💥 테이블 생성 실패:", error); - process.exit(1); - }); -} - -module.exports = { createComponentTable }; diff --git a/backend-node/scripts/init-layout-standards.js b/backend-node/scripts/init-layout-standards.js deleted file mode 100644 index 688a328d..00000000 --- a/backend-node/scripts/init-layout-standards.js +++ /dev/null @@ -1,309 +0,0 @@ -/** - * 레이아웃 표준 데이터 초기화 스크립트 - * 기본 레이아웃들을 layout_standards 테이블에 삽입합니다. - */ - -const { PrismaClient } = require("@prisma/client"); - -const prisma = new PrismaClient(); - -// 기본 레이아웃 데이터 -const PREDEFINED_LAYOUTS = [ - { - layout_code: "GRID_2X2_001", - layout_name: "2x2 그리드", - layout_name_eng: "2x2 Grid", - description: "2행 2열의 균등한 그리드 레이아웃입니다.", - layout_type: "grid", - category: "basic", - icon_name: "grid", - default_size: { width: 800, height: 600 }, - layout_config: { - grid: { rows: 2, columns: 2, gap: 16 }, - }, - zones_config: [ - { - id: "zone1", - name: "상단 좌측", - position: { row: 0, column: 0 }, - size: { width: "50%", height: "50%" }, - }, - { - id: "zone2", - name: "상단 우측", - position: { row: 0, column: 1 }, - size: { width: "50%", height: "50%" }, - }, - { - id: "zone3", - name: "하단 좌측", - position: { row: 1, column: 0 }, - size: { width: "50%", height: "50%" }, - }, - { - id: "zone4", - name: "하단 우측", - position: { row: 1, column: 1 }, - size: { width: "50%", height: "50%" }, - }, - ], - sort_order: 1, - is_active: "Y", - is_public: "Y", - company_code: "DEFAULT", - }, - { - layout_code: "FORM_TWO_COLUMN_001", - layout_name: "2단 폼 레이아웃", - layout_name_eng: "Two Column Form", - description: "좌우 2단으로 구성된 폼 레이아웃입니다.", - layout_type: "grid", - category: "form", - icon_name: "columns", - default_size: { width: 800, height: 400 }, - layout_config: { - grid: { rows: 1, columns: 2, gap: 24 }, - }, - zones_config: [ - { - id: "left", - name: "좌측 입력 영역", - position: { row: 0, column: 0 }, - size: { width: "50%", height: "100%" }, - }, - { - id: "right", - name: "우측 입력 영역", - position: { row: 0, column: 1 }, - size: { width: "50%", height: "100%" }, - }, - ], - sort_order: 2, - is_active: "Y", - is_public: "Y", - company_code: "DEFAULT", - }, - { - layout_code: "FLEXBOX_ROW_001", - layout_name: "가로 플렉스박스", - layout_name_eng: "Horizontal Flexbox", - description: "가로 방향으로 배치되는 플렉스박스 레이아웃입니다.", - layout_type: "flexbox", - category: "basic", - icon_name: "flex", - default_size: { width: 800, height: 300 }, - layout_config: { - flexbox: { - direction: "row", - justify: "flex-start", - align: "stretch", - wrap: "nowrap", - gap: 16, - }, - }, - zones_config: [ - { - id: "left", - name: "좌측 영역", - position: {}, - size: { width: "50%", height: "100%" }, - }, - { - id: "right", - name: "우측 영역", - position: {}, - size: { width: "50%", height: "100%" }, - }, - ], - sort_order: 3, - is_active: "Y", - is_public: "Y", - company_code: "DEFAULT", - }, - { - layout_code: "SPLIT_HORIZONTAL_001", - layout_name: "수평 분할", - layout_name_eng: "Horizontal Split", - description: "크기 조절이 가능한 수평 분할 레이아웃입니다.", - layout_type: "split", - category: "basic", - icon_name: "separator-horizontal", - default_size: { width: 800, height: 400 }, - layout_config: { - split: { - direction: "horizontal", - ratio: [50, 50], - minSize: [200, 200], - resizable: true, - splitterSize: 4, - }, - }, - zones_config: [ - { - id: "left", - name: "좌측 패널", - position: {}, - size: { width: "50%", height: "100%" }, - isResizable: true, - }, - { - id: "right", - name: "우측 패널", - position: {}, - size: { width: "50%", height: "100%" }, - isResizable: true, - }, - ], - sort_order: 4, - is_active: "Y", - is_public: "Y", - company_code: "DEFAULT", - }, - { - layout_code: "TABS_HORIZONTAL_001", - layout_name: "수평 탭", - layout_name_eng: "Horizontal Tabs", - description: "상단에 탭이 있는 탭 레이아웃입니다.", - layout_type: "tabs", - category: "navigation", - icon_name: "tabs", - default_size: { width: 800, height: 500 }, - layout_config: { - tabs: { - position: "top", - variant: "default", - size: "md", - defaultTab: "tab1", - closable: false, - }, - }, - zones_config: [ - { - id: "tab1", - name: "첫 번째 탭", - position: {}, - size: { width: "100%", height: "100%" }, - }, - { - id: "tab2", - name: "두 번째 탭", - position: {}, - size: { width: "100%", height: "100%" }, - }, - { - id: "tab3", - name: "세 번째 탭", - position: {}, - size: { width: "100%", height: "100%" }, - }, - ], - sort_order: 5, - is_active: "Y", - is_public: "Y", - company_code: "DEFAULT", - }, - { - layout_code: "TABLE_WITH_FILTERS_001", - layout_name: "필터가 있는 테이블", - layout_name_eng: "Table with Filters", - description: "상단에 필터가 있고 하단에 테이블이 있는 레이아웃입니다.", - layout_type: "flexbox", - category: "table", - icon_name: "table", - default_size: { width: 1000, height: 600 }, - layout_config: { - flexbox: { - direction: "column", - justify: "flex-start", - align: "stretch", - wrap: "nowrap", - gap: 16, - }, - }, - zones_config: [ - { - id: "filters", - name: "검색 필터", - position: {}, - size: { width: "100%", height: "auto" }, - }, - { - id: "table", - name: "데이터 테이블", - position: {}, - size: { width: "100%", height: "1fr" }, - }, - ], - sort_order: 6, - is_active: "Y", - is_public: "Y", - company_code: "DEFAULT", - }, -]; - -async function initializeLayoutStandards() { - try { - console.log("🏗️ 레이아웃 표준 데이터 초기화 시작..."); - - // 기존 데이터 확인 - const existingLayouts = await prisma.layout_standards.count(); - if (existingLayouts > 0) { - console.log(`⚠️ 이미 ${existingLayouts}개의 레이아웃이 존재합니다.`); - console.log( - "기존 데이터를 삭제하고 새로 생성하시겠습니까? (기본값: 건너뛰기)" - ); - - // 기존 데이터가 있으면 건너뛰기 (안전을 위해) - console.log("💡 기존 데이터를 유지하고 건너뜁니다."); - return; - } - - // 데이터 삽입 - let insertedCount = 0; - - for (const layoutData of PREDEFINED_LAYOUTS) { - try { - await prisma.layout_standards.create({ - data: { - ...layoutData, - created_date: new Date(), - updated_date: new Date(), - created_by: "SYSTEM", - updated_by: "SYSTEM", - }, - }); - - console.log(`✅ ${layoutData.layout_name} 생성 완료`); - insertedCount++; - } catch (error) { - console.error(`❌ ${layoutData.layout_name} 생성 실패:`, error.message); - } - } - - console.log( - `🎉 레이아웃 표준 데이터 초기화 완료! (${insertedCount}/${PREDEFINED_LAYOUTS.length})` - ); - } catch (error) { - console.error("❌ 레이아웃 표준 데이터 초기화 실패:", error); - throw error; - } -} - -// 스크립트 실행 -if (require.main === module) { - initializeLayoutStandards() - .then(() => { - console.log("✨ 스크립트 실행 완료"); - process.exit(0); - }) - .catch((error) => { - console.error("💥 스크립트 실행 실패:", error); - process.exit(1); - }) - .finally(async () => { - await prisma.$disconnect(); - }); -} - -module.exports = { initializeLayoutStandards }; - diff --git a/backend-node/scripts/install-dataflow-indexes.js b/backend-node/scripts/install-dataflow-indexes.js deleted file mode 100644 index 0c62dc1a..00000000 --- a/backend-node/scripts/install-dataflow-indexes.js +++ /dev/null @@ -1,200 +0,0 @@ -/** - * 🔥 버튼 제어관리 성능 최적화 인덱스 설치 스크립트 - * - * 사용법: - * node scripts/install-dataflow-indexes.js - */ - -const { PrismaClient } = require("@prisma/client"); -const fs = require("fs"); -const path = require("path"); - -const prisma = new PrismaClient(); - -async function installDataflowIndexes() { - try { - console.log("🔥 Starting Button Dataflow Performance Optimization...\n"); - - // SQL 파일 읽기 - const sqlFilePath = path.join( - __dirname, - "../database/migrations/add_button_dataflow_indexes.sql" - ); - const sqlContent = fs.readFileSync(sqlFilePath, "utf8"); - - console.log("📖 Reading SQL migration file..."); - console.log(`📁 File: ${sqlFilePath}\n`); - - // 데이터베이스 연결 확인 - console.log("🔍 Checking database connection..."); - await prisma.$queryRaw`SELECT 1`; - console.log("✅ Database connection OK\n"); - - // 기존 인덱스 상태 확인 - console.log("🔍 Checking existing indexes..."); - const existingIndexes = await prisma.$queryRaw` - SELECT indexname, tablename - FROM pg_indexes - WHERE tablename = 'dataflow_diagrams' - AND indexname LIKE 'idx_dataflow%' - ORDER BY indexname; - `; - - if (existingIndexes.length > 0) { - console.log("📋 Existing dataflow indexes:"); - existingIndexes.forEach((idx) => { - console.log(` - ${idx.indexname}`); - }); - } else { - console.log("📋 No existing dataflow indexes found"); - } - console.log(""); - - // 테이블 상태 확인 - console.log("🔍 Checking dataflow_diagrams table stats..."); - const tableStats = await prisma.$queryRaw` - SELECT - COUNT(*) as total_rows, - COUNT(*) FILTER (WHERE control IS NOT NULL) as with_control, - COUNT(*) FILTER (WHERE plan IS NOT NULL) as with_plan, - COUNT(*) FILTER (WHERE category IS NOT NULL) as with_category, - COUNT(DISTINCT company_code) as companies - FROM dataflow_diagrams; - `; - - if (tableStats.length > 0) { - const stats = tableStats[0]; - console.log(`📊 Table Statistics:`); - console.log(` - Total rows: ${stats.total_rows}`); - console.log(` - With control: ${stats.with_control}`); - console.log(` - With plan: ${stats.with_plan}`); - console.log(` - With category: ${stats.with_category}`); - console.log(` - Companies: ${stats.companies}`); - } - console.log(""); - - // SQL 실행 - console.log("🚀 Installing performance indexes..."); - console.log("⏳ This may take a few minutes for large datasets...\n"); - - const startTime = Date.now(); - - // SQL을 문장별로 나누어 실행 (PostgreSQL 함수 때문에) - const sqlStatements = sqlContent - .split(/;\s*(?=\n|$)/) - .filter( - (stmt) => - stmt.trim().length > 0 && - !stmt.trim().startsWith("--") && - !stmt.trim().startsWith("/*") - ); - - for (let i = 0; i < sqlStatements.length; i++) { - const statement = sqlStatements[i].trim(); - if (statement.length === 0) continue; - - try { - // DO 블록이나 복합 문장 처리 - if ( - statement.includes("DO $$") || - statement.includes("CREATE OR REPLACE VIEW") - ) { - console.log( - `⚡ Executing statement ${i + 1}/${sqlStatements.length}...` - ); - await prisma.$executeRawUnsafe(statement + ";"); - } else if (statement.startsWith("CREATE INDEX")) { - const indexName = - statement.match(/CREATE INDEX[^"]*"?([^"\s]+)"?/)?.[1] || "unknown"; - console.log(`🔧 Creating index: ${indexName}...`); - await prisma.$executeRawUnsafe(statement + ";"); - } else if (statement.startsWith("ANALYZE")) { - console.log(`📊 Analyzing table statistics...`); - await prisma.$executeRawUnsafe(statement + ";"); - } else { - await prisma.$executeRawUnsafe(statement + ";"); - } - } catch (error) { - // 이미 존재하는 인덱스 에러는 무시 - if (error.message.includes("already exists")) { - console.log(`⚠️ Index already exists, skipping...`); - } else { - console.error(`❌ Error executing statement: ${error.message}`); - console.error(`📝 Statement: ${statement.substring(0, 100)}...`); - } - } - } - - const endTime = Date.now(); - const executionTime = (endTime - startTime) / 1000; - - console.log( - `\n✅ Index installation completed in ${executionTime.toFixed(2)} seconds!` - ); - - // 설치된 인덱스 확인 - console.log("\n🔍 Verifying installed indexes..."); - const newIndexes = await prisma.$queryRaw` - SELECT - indexname, - pg_size_pretty(pg_relation_size(indexrelid)) as size - FROM pg_stat_user_indexes - WHERE tablename = 'dataflow_diagrams' - AND indexname LIKE 'idx_dataflow%' - ORDER BY indexname; - `; - - if (newIndexes.length > 0) { - console.log("📋 Installed indexes:"); - newIndexes.forEach((idx) => { - console.log(` ✅ ${idx.indexname} (${idx.size})`); - }); - } - - // 성능 통계 조회 - console.log("\n📊 Performance statistics:"); - try { - const perfStats = - await prisma.$queryRaw`SELECT * FROM dataflow_performance_stats;`; - if (perfStats.length > 0) { - const stats = perfStats[0]; - console.log(` - Table size: ${stats.table_size}`); - console.log(` - Total diagrams: ${stats.total_rows}`); - console.log(` - With control: ${stats.with_control}`); - console.log(` - Companies: ${stats.companies}`); - } - } catch (error) { - console.log(" ⚠️ Performance view not available yet"); - } - - console.log("\n🎯 Performance Optimization Complete!"); - console.log("Expected improvements:"); - console.log(" - Button dataflow lookup: 500ms+ → 10-50ms ⚡"); - console.log(" - Category filtering: 200ms+ → 5-20ms ⚡"); - console.log(" - Company queries: 100ms+ → 5-15ms ⚡"); - - console.log("\n💡 Monitor performance with:"); - console.log(" SELECT * FROM dataflow_performance_stats;"); - console.log(" SELECT * FROM dataflow_index_efficiency;"); - } catch (error) { - console.error("\n❌ Error installing dataflow indexes:", error); - process.exit(1); - } finally { - await prisma.$disconnect(); - } -} - -// 실행 -if (require.main === module) { - installDataflowIndexes() - .then(() => { - console.log("\n🎉 Installation completed successfully!"); - process.exit(0); - }) - .catch((error) => { - console.error("\n💥 Installation failed:", error); - process.exit(1); - }); -} - -module.exports = { installDataflowIndexes }; diff --git a/backend-node/scripts/list-components.js b/backend-node/scripts/list-components.js deleted file mode 100644 index a0ba6da4..00000000 --- a/backend-node/scripts/list-components.js +++ /dev/null @@ -1,46 +0,0 @@ -const { PrismaClient } = require("@prisma/client"); -const prisma = new PrismaClient(); - -async function getComponents() { - try { - const components = await prisma.component_standards.findMany({ - where: { is_active: "Y" }, - select: { - component_code: true, - component_name: true, - category: true, - component_config: true, - }, - orderBy: [{ category: "asc" }, { sort_order: "asc" }], - }); - - console.log("📋 데이터베이스 컴포넌트 목록:"); - console.log("=".repeat(60)); - - const grouped = components.reduce((acc, comp) => { - if (!acc[comp.category]) { - acc[comp.category] = []; - } - acc[comp.category].push(comp); - return acc; - }, {}); - - Object.entries(grouped).forEach(([category, comps]) => { - console.log(`\n🏷️ ${category.toUpperCase()} 카테고리:`); - comps.forEach((comp) => { - const type = comp.component_config?.type || "unknown"; - console.log( - ` - ${comp.component_code}: ${comp.component_name} (type: ${type})` - ); - }); - }); - - console.log(`\n총 ${components.length}개 컴포넌트 발견`); - } catch (error) { - console.error("Error:", error); - } finally { - await prisma.$disconnect(); - } -} - -getComponents(); diff --git a/backend-node/scripts/migrate-input-type-to-web-type.ts b/backend-node/scripts/migrate-input-type-to-web-type.ts deleted file mode 100644 index 65c64b14..00000000 --- a/backend-node/scripts/migrate-input-type-to-web-type.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { query } from "../src/database/db"; -import { logger } from "../src/utils/logger"; - -/** - * input_type을 web_type으로 마이그레이션하는 스크립트 - * - * 목적: - * - column_labels 테이블의 input_type 값을 읽어서 - * - 해당하는 기본 web_type 값으로 변환 - * - web_type이 null인 경우에만 업데이트 - */ - -// input_type → 기본 web_type 매핑 -const INPUT_TYPE_TO_WEB_TYPE: Record = { - text: "text", // 일반 텍스트 - number: "number", // 정수 - date: "date", // 날짜 - code: "code", // 코드 선택박스 - entity: "entity", // 엔티티 참조 - select: "select", // 선택박스 - checkbox: "checkbox", // 체크박스 - radio: "radio", // 라디오버튼 - direct: "text", // direct는 text로 매핑 -}; - -async function migrateInputTypeToWebType() { - try { - logger.info("=".repeat(60)); - logger.info("input_type → web_type 마이그레이션 시작"); - logger.info("=".repeat(60)); - - // 1. 현재 상태 확인 - const stats = await query<{ - total: string; - has_input_type: string; - has_web_type: string; - needs_migration: string; - }>( - `SELECT - COUNT(*) as total, - COUNT(input_type) FILTER (WHERE input_type IS NOT NULL) as has_input_type, - COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type, - COUNT(*) FILTER (WHERE input_type IS NOT NULL AND web_type IS NULL) as needs_migration - FROM column_labels` - ); - - const stat = stats[0]; - logger.info("\n📊 현재 상태:"); - logger.info(` - 전체 컬럼: ${stat.total}개`); - logger.info(` - input_type 있음: ${stat.has_input_type}개`); - logger.info(` - web_type 있음: ${stat.has_web_type}개`); - logger.info(` - 마이그레이션 필요: ${stat.needs_migration}개`); - - if (parseInt(stat.needs_migration) === 0) { - logger.info("\n✅ 마이그레이션이 필요한 데이터가 없습니다."); - return; - } - - // 2. input_type별 분포 확인 - const distribution = await query<{ - input_type: string; - count: string; - }>( - `SELECT - input_type, - COUNT(*) as count - FROM column_labels - WHERE input_type IS NOT NULL AND web_type IS NULL - GROUP BY input_type - ORDER BY input_type` - ); - - logger.info("\n📋 input_type별 분포:"); - distribution.forEach((item) => { - const webType = - INPUT_TYPE_TO_WEB_TYPE[item.input_type] || item.input_type; - logger.info(` - ${item.input_type} → ${webType}: ${item.count}개`); - }); - - // 3. 마이그레이션 실행 - logger.info("\n🔄 마이그레이션 실행 중..."); - - let totalUpdated = 0; - - for (const [inputType, webType] of Object.entries(INPUT_TYPE_TO_WEB_TYPE)) { - const result = await query( - `UPDATE column_labels - SET - web_type = $1, - updated_date = NOW() - WHERE input_type = $2 - AND web_type IS NULL - RETURNING id, table_name, column_name`, - [webType, inputType] - ); - - if (result.length > 0) { - logger.info( - ` ✓ ${inputType} → ${webType}: ${result.length}개 업데이트` - ); - totalUpdated += result.length; - - // 처음 5개만 출력 - result.slice(0, 5).forEach((row: any) => { - logger.info(` - ${row.table_name}.${row.column_name}`); - }); - if (result.length > 5) { - logger.info(` ... 외 ${result.length - 5}개`); - } - } - } - - // 4. 결과 확인 - const afterStats = await query<{ - total: string; - has_web_type: string; - }>( - `SELECT - COUNT(*) as total, - COUNT(web_type) FILTER (WHERE web_type IS NOT NULL) as has_web_type - FROM column_labels` - ); - - const afterStat = afterStats[0]; - - logger.info("\n" + "=".repeat(60)); - logger.info("✅ 마이그레이션 완료!"); - logger.info("=".repeat(60)); - logger.info(`📊 최종 통계:`); - logger.info(` - 전체 컬럼: ${afterStat.total}개`); - logger.info(` - web_type 설정됨: ${afterStat.has_web_type}개`); - logger.info(` - 업데이트된 컬럼: ${totalUpdated}개`); - logger.info("=".repeat(60)); - - // 5. 샘플 데이터 출력 - logger.info("\n📝 샘플 데이터 (check_report_mng 테이블):"); - const samples = await query<{ - column_name: string; - input_type: string; - web_type: string; - detail_settings: string; - }>( - `SELECT - column_name, - input_type, - web_type, - detail_settings - FROM column_labels - WHERE table_name = 'check_report_mng' - ORDER BY column_name - LIMIT 10` - ); - - samples.forEach((sample) => { - logger.info( - ` ${sample.column_name}: ${sample.input_type} → ${sample.web_type}` - ); - }); - - process.exit(0); - } catch (error) { - logger.error("❌ 마이그레이션 실패:", error); - process.exit(1); - } -} - -// 스크립트 실행 -migrateInputTypeToWebType(); diff --git a/backend-node/scripts/run-1050-migration.js b/backend-node/scripts/run-1050-migration.js deleted file mode 100644 index aa1b3723..00000000 --- a/backend-node/scripts/run-1050-migration.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * system_notice 테이블 생성 마이그레이션 실행 - */ -const { Pool } = require('pg'); -const fs = require('fs'); -const path = require('path'); - -const pool = new Pool({ - connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm', - ssl: false, -}); - -async function run() { - const client = await pool.connect(); - try { - const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql'); - const sql = fs.readFileSync(sqlPath, 'utf8'); - await client.query(sql); - console.log('OK: system_notice 테이블 생성 완료'); - - // 검증 - const result = await client.query( - "SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position" - ); - console.log('컬럼:', result.rows.map(r => r.column_name).join(', ')); - } catch (e) { - console.error('ERROR:', e.message); - process.exit(1); - } finally { - client.release(); - await pool.end(); - } -} - -run(); diff --git a/backend-node/scripts/run-migration.js b/backend-node/scripts/run-migration.js deleted file mode 100644 index 39419ce6..00000000 --- a/backend-node/scripts/run-migration.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * SQL 마이그레이션 실행 스크립트 - * 사용법: node scripts/run-migration.js - */ - -const fs = require('fs'); -const path = require('path'); -const { Pool } = require('pg'); - -// DATABASE_URL에서 연결 정보 파싱 -const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm'; - -// 데이터베이스 연결 설정 -const pool = new Pool({ - connectionString: databaseUrl, -}); - -async function runMigration() { - const client = await pool.connect(); - - try { - console.log('🔄 마이그레이션 시작...\n'); - - // SQL 파일 읽기 (Docker 컨테이너 내부 경로) - const sqlPath = '/tmp/migration.sql'; - const sql = fs.readFileSync(sqlPath, 'utf8'); - - console.log('📄 SQL 파일 로드 완료'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); - - // SQL 실행 - await client.query(sql); - - console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log('✅ 마이그레이션 성공적으로 완료되었습니다!'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); - - } catch (error) { - console.error('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.error('❌ 마이그레이션 실패:'); - console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.error(error); - console.error('\n💡 롤백이 필요한 경우 롤백 스크립트를 실행하세요.'); - process.exit(1); - } finally { - client.release(); - await pool.end(); - } -} - -// 실행 -runMigration(); - diff --git a/backend-node/scripts/run-notice-migration.js b/backend-node/scripts/run-notice-migration.js deleted file mode 100644 index 4b23153d..00000000 --- a/backend-node/scripts/run-notice-migration.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * system_notice 마이그레이션 실행 스크립트 - * 사용법: node scripts/run-notice-migration.js - */ -const fs = require('fs'); -const path = require('path'); -const { Pool } = require('pg'); - -const pool = new Pool({ - connectionString: process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm', - ssl: false, -}); - -async function run() { - const client = await pool.connect(); - try { - const sqlPath = path.join(__dirname, '../../db/migrations/1050_create_system_notice.sql'); - const sql = fs.readFileSync(sqlPath, 'utf8'); - - console.log('마이그레이션 실행 중...'); - await client.query(sql); - console.log('마이그레이션 완료'); - - // 컬럼 확인 - const check = await client.query( - "SELECT column_name FROM information_schema.columns WHERE table_name='system_notice' ORDER BY ordinal_position" - ); - console.log('테이블 컬럼:', check.rows.map(r => r.column_name).join(', ')); - } catch (e) { - console.error('오류:', e.message); - process.exit(1); - } finally { - client.release(); - await pool.end(); - } -} - -run(); diff --git a/backend-node/scripts/seed-templates.js b/backend-node/scripts/seed-templates.js deleted file mode 100644 index f72b53ea..00000000 --- a/backend-node/scripts/seed-templates.js +++ /dev/null @@ -1,294 +0,0 @@ -const { PrismaClient } = require("@prisma/client"); - -const prisma = new PrismaClient(); - -// 기본 템플릿 데이터 정의 -const defaultTemplates = [ - { - template_code: "advanced-data-table-v2", - template_name: "고급 데이터 테이블 v2", - template_name_eng: "Advanced Data Table v2", - description: - "검색, 필터링, 페이징, CRUD 기능이 포함된 완전한 데이터 테이블 컴포넌트", - category: "table", - icon_name: "table", - default_size: { - width: 1000, - height: 680, - }, - layout_config: { - components: [ - { - type: "datatable", - label: "고급 데이터 테이블", - position: { x: 0, y: 0 }, - size: { width: 1000, height: 680 }, - style: { - border: "1px solid #e5e7eb", - borderRadius: "8px", - backgroundColor: "#ffffff", - padding: "0", - }, - columns: [ - { - id: "id", - label: "ID", - type: "number", - visible: true, - sortable: true, - filterable: false, - width: 80, - }, - { - id: "name", - label: "이름", - type: "text", - visible: true, - sortable: true, - filterable: true, - width: 150, - }, - { - id: "email", - label: "이메일", - type: "email", - visible: true, - sortable: true, - filterable: true, - width: 200, - }, - { - id: "status", - label: "상태", - type: "select", - visible: true, - sortable: true, - filterable: true, - width: 100, - }, - { - id: "created_date", - label: "생성일", - type: "date", - visible: true, - sortable: true, - filterable: true, - width: 120, - }, - ], - filters: [ - { - id: "status", - label: "상태", - type: "select", - options: [ - { label: "전체", value: "" }, - { label: "활성", value: "active" }, - { label: "비활성", value: "inactive" }, - ], - }, - { id: "name", label: "이름", type: "text" }, - { id: "email", label: "이메일", type: "text" }, - ], - pagination: { - enabled: true, - pageSize: 10, - pageSizeOptions: [5, 10, 20, 50, 100], - showPageSizeSelector: true, - showPageInfo: true, - showFirstLast: true, - }, - actions: { - showSearchButton: true, - searchButtonText: "검색", - enableExport: true, - enableRefresh: true, - enableAdd: true, - enableEdit: true, - enableDelete: true, - addButtonText: "추가", - editButtonText: "수정", - deleteButtonText: "삭제", - }, - addModalConfig: { - title: "새 데이터 추가", - description: "테이블에 새로운 데이터를 추가합니다.", - width: "lg", - layout: "two-column", - gridColumns: 2, - fieldOrder: ["name", "email", "status"], - requiredFields: ["name", "email"], - hiddenFields: ["id", "created_date"], - advancedFieldConfigs: { - status: { - type: "select", - options: [ - { label: "활성", value: "active" }, - { label: "비활성", value: "inactive" }, - ], - }, - }, - submitButtonText: "추가", - cancelButtonText: "취소", - }, - }, - ], - }, - sort_order: 1, - is_active: "Y", - is_public: "Y", - company_code: "*", - created_by: "system", - updated_by: "system", - }, - { - template_code: "universal-button", - template_name: "범용 버튼", - template_name_eng: "Universal Button", - description: - "다양한 기능을 설정할 수 있는 범용 버튼. 상세설정에서 기능을 선택하세요.", - category: "button", - icon_name: "mouse-pointer", - default_size: { - width: 80, - height: 36, - }, - layout_config: { - components: [ - { - type: "widget", - widgetType: "button", - label: "버튼", - position: { x: 0, y: 0 }, - size: { width: 80, height: 36 }, - style: { - backgroundColor: "#3b82f6", - color: "#ffffff", - border: "none", - borderRadius: "6px", - fontSize: "14px", - fontWeight: "500", - }, - }, - ], - }, - sort_order: 2, - is_active: "Y", - is_public: "Y", - company_code: "*", - created_by: "system", - updated_by: "system", - }, - { - template_code: "file-upload", - template_name: "파일 첨부", - template_name_eng: "File Upload", - description: "드래그앤드롭 파일 업로드 영역", - category: "file", - icon_name: "upload", - default_size: { - width: 300, - height: 120, - }, - layout_config: { - components: [ - { - type: "widget", - widgetType: "file", - label: "파일 첨부", - position: { x: 0, y: 0 }, - size: { width: 300, height: 120 }, - style: { - border: "2px dashed #d1d5db", - borderRadius: "8px", - backgroundColor: "#f9fafb", - display: "flex", - alignItems: "center", - justifyContent: "center", - fontSize: "14px", - color: "#6b7280", - }, - }, - ], - }, - sort_order: 3, - is_active: "Y", - is_public: "Y", - company_code: "*", - created_by: "system", - updated_by: "system", - }, - { - template_code: "form-container", - template_name: "폼 컨테이너", - template_name_eng: "Form Container", - description: "입력 폼을 위한 기본 컨테이너 레이아웃", - category: "form", - icon_name: "form", - default_size: { - width: 400, - height: 300, - }, - layout_config: { - components: [ - { - type: "container", - label: "폼 컨테이너", - position: { x: 0, y: 0 }, - size: { width: 400, height: 300 }, - style: { - border: "1px solid #e5e7eb", - borderRadius: "8px", - backgroundColor: "#ffffff", - padding: "16px", - }, - }, - ], - }, - sort_order: 4, - is_active: "Y", - is_public: "Y", - company_code: "*", - created_by: "system", - updated_by: "system", - }, -]; - -async function seedTemplates() { - console.log("🌱 템플릿 시드 데이터 삽입 시작..."); - - try { - // 기존 템플릿이 있는지 확인하고 없는 경우에만 삽입 - for (const template of defaultTemplates) { - const existing = await prisma.template_standards.findUnique({ - where: { template_code: template.template_code }, - }); - - if (!existing) { - await prisma.template_standards.create({ - data: template, - }); - console.log(`✅ 템플릿 '${template.template_name}' 생성됨`); - } else { - console.log(`⏭️ 템플릿 '${template.template_name}' 이미 존재함`); - } - } - - console.log("🎉 템플릿 시드 데이터 삽입 완료!"); - } catch (error) { - console.error("❌ 템플릿 시드 데이터 삽입 실패:", error); - throw error; - } finally { - await prisma.$disconnect(); - } -} - -// 스크립트가 직접 실행될 때만 시드 함수 실행 -if (require.main === module) { - seedTemplates().catch((error) => { - console.error(error); - process.exit(1); - }); -} - -module.exports = { seedTemplates }; diff --git a/backend-node/scripts/seed-ui-components.js b/backend-node/scripts/seed-ui-components.js deleted file mode 100644 index 78a71ead..00000000 --- a/backend-node/scripts/seed-ui-components.js +++ /dev/null @@ -1,411 +0,0 @@ -const { PrismaClient } = require("@prisma/client"); - -const prisma = new PrismaClient(); - -// 실제 UI 구성에 필요한 컴포넌트들 -const uiComponents = [ - // === 액션 컴포넌트 === - { - component_code: "button-primary", - component_name: "기본 버튼", - component_name_eng: "Primary Button", - description: "일반적인 액션을 위한 기본 버튼 컴포넌트", - category: "action", - icon_name: "MousePointer", - default_size: { width: 100, height: 36 }, - component_config: { - type: "button", - variant: "primary", - text: "버튼", - action: "custom", - style: { - backgroundColor: "#3b82f6", - color: "#ffffff", - borderRadius: "6px", - fontSize: "14px", - fontWeight: "500", - }, - }, - sort_order: 10, - }, - { - component_code: "button-secondary", - component_name: "보조 버튼", - component_name_eng: "Secondary Button", - description: "보조 액션을 위한 버튼 컴포넌트", - category: "action", - icon_name: "MousePointer", - default_size: { width: 100, height: 36 }, - component_config: { - type: "button", - variant: "secondary", - text: "취소", - action: "cancel", - style: { - backgroundColor: "#f1f5f9", - color: "#475569", - borderRadius: "6px", - fontSize: "14px", - }, - }, - sort_order: 11, - }, - - // === 레이아웃 컴포넌트 === - { - component_code: "card-basic", - component_name: "기본 카드", - component_name_eng: "Basic Card", - description: "정보를 그룹화하는 기본 카드 컴포넌트", - category: "layout", - icon_name: "Square", - default_size: { width: 400, height: 300 }, - component_config: { - type: "card", - title: "카드 제목", - showHeader: true, - showFooter: false, - style: { - backgroundColor: "#ffffff", - border: "1px solid #e5e7eb", - borderRadius: "8px", - padding: "16px", - boxShadow: "0 1px 3px rgba(0, 0, 0, 0.1)", - }, - }, - sort_order: 20, - }, - { - component_code: "dashboard-grid", - component_name: "대시보드 그리드", - component_name_eng: "Dashboard Grid", - description: "대시보드를 위한 그리드 레이아웃 컴포넌트", - category: "layout", - icon_name: "LayoutGrid", - default_size: { width: 800, height: 600 }, - component_config: { - type: "dashboard", - columns: 3, - gap: 16, - items: [], - style: { - backgroundColor: "#f8fafc", - padding: "20px", - borderRadius: "8px", - }, - }, - sort_order: 21, - }, - { - component_code: "panel-collapsible", - component_name: "접을 수 있는 패널", - component_name_eng: "Collapsible Panel", - description: "접고 펼칠 수 있는 패널 컴포넌트", - category: "layout", - icon_name: "ChevronDown", - default_size: { width: 500, height: 200 }, - component_config: { - type: "panel", - title: "패널 제목", - collapsible: true, - defaultExpanded: true, - style: { - backgroundColor: "#ffffff", - border: "1px solid #e5e7eb", - borderRadius: "8px", - }, - }, - sort_order: 22, - }, - - // === 데이터 표시 컴포넌트 === - { - component_code: "stats-card", - component_name: "통계 카드", - component_name_eng: "Statistics Card", - description: "수치와 통계를 표시하는 카드 컴포넌트", - category: "data", - icon_name: "BarChart3", - default_size: { width: 250, height: 120 }, - component_config: { - type: "stats", - title: "총 판매량", - value: "1,234", - unit: "개", - trend: "up", - percentage: "+12.5%", - style: { - backgroundColor: "#ffffff", - border: "1px solid #e5e7eb", - borderRadius: "8px", - padding: "20px", - }, - }, - sort_order: 30, - }, - { - component_code: "progress-bar", - component_name: "진행률 표시", - component_name_eng: "Progress Bar", - description: "작업 진행률을 표시하는 컴포넌트", - category: "data", - icon_name: "BarChart2", - default_size: { width: 300, height: 60 }, - component_config: { - type: "progress", - label: "진행률", - value: 65, - max: 100, - showPercentage: true, - style: { - backgroundColor: "#f1f5f9", - borderRadius: "4px", - height: "8px", - }, - }, - sort_order: 31, - }, - { - component_code: "chart-basic", - component_name: "기본 차트", - component_name_eng: "Basic Chart", - description: "데이터를 시각화하는 기본 차트 컴포넌트", - category: "data", - icon_name: "TrendingUp", - default_size: { width: 500, height: 300 }, - component_config: { - type: "chart", - chartType: "line", - title: "차트 제목", - data: [], - options: { - responsive: true, - plugins: { - legend: { position: "top" }, - }, - }, - }, - sort_order: 32, - }, - - // === 네비게이션 컴포넌트 === - { - component_code: "breadcrumb", - component_name: "브레드크럼", - component_name_eng: "Breadcrumb", - description: "현재 위치를 표시하는 네비게이션 컴포넌트", - category: "navigation", - icon_name: "ChevronRight", - default_size: { width: 400, height: 32 }, - component_config: { - type: "breadcrumb", - items: [ - { label: "홈", href: "/" }, - { label: "관리자", href: "/admin" }, - { label: "현재 페이지" }, - ], - separator: ">", - }, - sort_order: 40, - }, - { - component_code: "tabs-horizontal", - component_name: "가로 탭", - component_name_eng: "Horizontal Tabs", - description: "컨텐츠를 탭으로 구분하는 네비게이션 컴포넌트", - category: "navigation", - icon_name: "Tabs", - default_size: { width: 500, height: 300 }, - component_config: { - type: "tabs", - orientation: "horizontal", - tabs: [ - { id: "tab1", label: "탭 1", content: "첫 번째 탭 내용" }, - { id: "tab2", label: "탭 2", content: "두 번째 탭 내용" }, - ], - defaultTab: "tab1", - }, - sort_order: 41, - }, - { - component_code: "pagination", - component_name: "페이지네이션", - component_name_eng: "Pagination", - description: "페이지를 나눠서 표시하는 네비게이션 컴포넌트", - category: "navigation", - icon_name: "ChevronLeft", - default_size: { width: 300, height: 40 }, - component_config: { - type: "pagination", - currentPage: 1, - totalPages: 10, - showFirst: true, - showLast: true, - showPrevNext: true, - }, - sort_order: 42, - }, - - // === 피드백 컴포넌트 === - { - component_code: "alert-info", - component_name: "정보 알림", - component_name_eng: "Info Alert", - description: "정보를 사용자에게 알리는 컴포넌트", - category: "feedback", - icon_name: "Info", - default_size: { width: 400, height: 60 }, - component_config: { - type: "alert", - variant: "info", - title: "알림", - message: "중요한 정보를 확인해주세요.", - dismissible: true, - icon: true, - }, - sort_order: 50, - }, - { - component_code: "badge-status", - component_name: "상태 뱃지", - component_name_eng: "Status Badge", - description: "상태나 카테고리를 표시하는 뱃지 컴포넌트", - category: "feedback", - icon_name: "Tag", - default_size: { width: 80, height: 24 }, - component_config: { - type: "badge", - text: "활성", - variant: "success", - size: "sm", - style: { - backgroundColor: "#10b981", - color: "#ffffff", - borderRadius: "12px", - fontSize: "12px", - }, - }, - sort_order: 51, - }, - { - component_code: "loading-spinner", - component_name: "로딩 스피너", - component_name_eng: "Loading Spinner", - description: "로딩 상태를 표시하는 스피너 컴포넌트", - category: "feedback", - icon_name: "RefreshCw", - default_size: { width: 100, height: 100 }, - component_config: { - type: "loading", - variant: "spinner", - size: "md", - message: "로딩 중...", - overlay: false, - }, - sort_order: 52, - }, - - // === 입력 컴포넌트 === - { - component_code: "search-box", - component_name: "검색 박스", - component_name_eng: "Search Box", - description: "검색 기능이 있는 입력 컴포넌트", - category: "input", - icon_name: "Search", - default_size: { width: 300, height: 40 }, - component_config: { - type: "search", - placeholder: "검색어를 입력하세요...", - showButton: true, - debounce: 500, - style: { - borderRadius: "20px", - border: "1px solid #d1d5db", - }, - }, - sort_order: 60, - }, - { - component_code: "filter-dropdown", - component_name: "필터 드롭다운", - component_name_eng: "Filter Dropdown", - description: "데이터 필터링을 위한 드롭다운 컴포넌트", - category: "input", - icon_name: "Filter", - default_size: { width: 200, height: 40 }, - component_config: { - type: "filter", - label: "필터", - options: [ - { value: "all", label: "전체" }, - { value: "active", label: "활성" }, - { value: "inactive", label: "비활성" }, - ], - defaultValue: "all", - multiple: false, - }, - sort_order: 61, - }, -]; - -async function seedUIComponents() { - try { - console.log("🚀 UI 컴포넌트 시딩 시작..."); - - // 기존 데이터 삭제 - console.log("📝 기존 컴포넌트 데이터 삭제 중..."); - await prisma.$executeRaw`DELETE FROM component_standards`; - - // 새 컴포넌트 데이터 삽입 - console.log("📦 새로운 UI 컴포넌트 삽입 중..."); - - for (const component of uiComponents) { - await prisma.component_standards.create({ - data: { - ...component, - company_code: "DEFAULT", - created_by: "system", - updated_by: "system", - }, - }); - console.log(`✅ ${component.component_name} 컴포넌트 생성됨`); - } - - console.log( - `\n🎉 총 ${uiComponents.length}개의 UI 컴포넌트가 성공적으로 생성되었습니다!` - ); - - // 카테고리별 통계 - const categoryCounts = {}; - uiComponents.forEach((component) => { - categoryCounts[component.category] = - (categoryCounts[component.category] || 0) + 1; - }); - - console.log("\n📊 카테고리별 컴포넌트 수:"); - Object.entries(categoryCounts).forEach(([category, count]) => { - console.log(` ${category}: ${count}개`); - }); - } catch (error) { - console.error("❌ UI 컴포넌트 시딩 실패:", error); - throw error; - } finally { - await prisma.$disconnect(); - } -} - -// 실행 -if (require.main === module) { - seedUIComponents() - .then(() => { - console.log("✨ UI 컴포넌트 시딩 완료!"); - process.exit(0); - }) - .catch((error) => { - console.error("💥 시딩 실패:", error); - process.exit(1); - }); -} - -module.exports = { seedUIComponents, uiComponents }; diff --git a/backend-node/scripts/test-digital-twin-db.ts b/backend-node/scripts/test-digital-twin-db.ts deleted file mode 100644 index 7d0efce7..00000000 --- a/backend-node/scripts/test-digital-twin-db.ts +++ /dev/null @@ -1,209 +0,0 @@ -/** - * 디지털 트윈 외부 DB (DO_DY) 연결 및 쿼리 테스트 스크립트 - * READ-ONLY: SELECT 쿼리만 실행 - */ - -import { Pool } from "pg"; -import mysql from "mysql2/promise"; -import { CredentialEncryption } from "../src/utils/credentialEncryption"; - -async function testDigitalTwinDb() { - // 내부 DB 연결 (연결 정보 저장용) - const internalPool = new Pool({ - host: process.env.DB_HOST || "localhost", - port: parseInt(process.env.DB_PORT || "5432"), - database: process.env.DB_NAME || "plm", - user: process.env.DB_USER || "postgres", - password: process.env.DB_PASSWORD || "ph0909!!", - }); - - const encryptionKey = - process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development"; - const encryption = new CredentialEncryption(encryptionKey); - - try { - console.log("🚀 디지털 트윈 외부 DB 연결 테스트 시작\n"); - - // 디지털 트윈 외부 DB 연결 정보 - const digitalTwinConnection = { - name: "디지털트윈_DO_DY", - description: "디지털 트윈 후판(자재) 재고 정보 데이터베이스 (MariaDB)", - dbType: "mysql", // MariaDB는 MySQL 프로토콜 사용 - host: "1.240.13.83", - port: 4307, - databaseName: "DO_DY", - username: "root", - password: "pohangms619!#", - sslEnabled: false, - isActive: true, - }; - - console.log("📝 연결 정보:"); - console.log(` - 이름: ${digitalTwinConnection.name}`); - console.log(` - DB 타입: ${digitalTwinConnection.dbType}`); - console.log(` - 호스트: ${digitalTwinConnection.host}:${digitalTwinConnection.port}`); - console.log(` - 데이터베이스: ${digitalTwinConnection.databaseName}\n`); - - // 1. 외부 DB 직접 연결 테스트 - console.log("🔍 외부 DB 직접 연결 테스트 중..."); - - const externalConnection = await mysql.createConnection({ - host: digitalTwinConnection.host, - port: digitalTwinConnection.port, - database: digitalTwinConnection.databaseName, - user: digitalTwinConnection.username, - password: digitalTwinConnection.password, - connectTimeout: 10000, - }); - - console.log("✅ 외부 DB 연결 성공!\n"); - - // 2. SELECT 쿼리 실행 - console.log("📊 WSTKKY 테이블 쿼리 실행 중...\n"); - - const query = ` - SELECT - SKUMKEY -- 제품번호 - , SKUDESC -- 자재명 - , SKUTHIC -- 두께 - , SKUWIDT -- 폭 - , SKULENG -- 길이 - , SKUWEIG -- 중량 - , STOTQTY -- 수량 - , SUOMKEY -- 단위 - FROM DO_DY.WSTKKY - LIMIT 10 - `; - - const [rows] = await externalConnection.execute(query); - - console.log("✅ 쿼리 실행 성공!\n"); - console.log(`📦 조회된 데이터: ${Array.isArray(rows) ? rows.length : 0}건\n`); - - if (Array.isArray(rows) && rows.length > 0) { - console.log("🔍 샘플 데이터 (첫 3건):\n"); - rows.slice(0, 3).forEach((row: any, index: number) => { - console.log(`[${index + 1}]`); - console.log(` 제품번호(SKUMKEY): ${row.SKUMKEY}`); - console.log(` 자재명(SKUDESC): ${row.SKUDESC}`); - console.log(` 두께(SKUTHIC): ${row.SKUTHIC}`); - console.log(` 폭(SKUWIDT): ${row.SKUWIDT}`); - console.log(` 길이(SKULENG): ${row.SKULENG}`); - console.log(` 중량(SKUWEIG): ${row.SKUWEIG}`); - console.log(` 수량(STOTQTY): ${row.STOTQTY}`); - console.log(` 단위(SUOMKEY): ${row.SUOMKEY}\n`); - }); - - // 전체 데이터 JSON 출력 - console.log("📄 전체 데이터 (JSON):"); - console.log(JSON.stringify(rows, null, 2)); - console.log("\n"); - } - - await externalConnection.end(); - - // 3. 내부 DB에 연결 정보 저장 - console.log("💾 내부 DB에 연결 정보 저장 중..."); - - const encryptedPassword = encryption.encrypt(digitalTwinConnection.password); - - // 중복 체크 - const existingResult = await internalPool.query( - "SELECT id FROM flow_external_db_connection WHERE name = $1", - [digitalTwinConnection.name] - ); - - let connectionId: number; - - if (existingResult.rows.length > 0) { - connectionId = existingResult.rows[0].id; - console.log(`⚠️ 이미 존재하는 연결 (ID: ${connectionId})`); - - // 기존 연결 업데이트 - await internalPool.query( - `UPDATE flow_external_db_connection - SET description = $1, - db_type = $2, - host = $3, - port = $4, - database_name = $5, - username = $6, - password_encrypted = $7, - ssl_enabled = $8, - is_active = $9, - updated_at = NOW(), - updated_by = 'system' - WHERE name = $10`, - [ - digitalTwinConnection.description, - digitalTwinConnection.dbType, - digitalTwinConnection.host, - digitalTwinConnection.port, - digitalTwinConnection.databaseName, - digitalTwinConnection.username, - encryptedPassword, - digitalTwinConnection.sslEnabled, - digitalTwinConnection.isActive, - digitalTwinConnection.name, - ] - ); - console.log(`✅ 연결 정보 업데이트 완료`); - } else { - // 새 연결 추가 - const result = await internalPool.query( - `INSERT INTO flow_external_db_connection ( - name, - description, - db_type, - host, - port, - database_name, - username, - password_encrypted, - ssl_enabled, - is_active, - created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system') - RETURNING id`, - [ - digitalTwinConnection.name, - digitalTwinConnection.description, - digitalTwinConnection.dbType, - digitalTwinConnection.host, - digitalTwinConnection.port, - digitalTwinConnection.databaseName, - digitalTwinConnection.username, - encryptedPassword, - digitalTwinConnection.sslEnabled, - digitalTwinConnection.isActive, - ] - ); - connectionId = result.rows[0].id; - console.log(`✅ 새 연결 추가 완료 (ID: ${connectionId})`); - } - - console.log("\n✅ 모든 테스트 완료!"); - console.log(`\n📌 연결 ID: ${connectionId}`); - console.log(" 이 ID를 사용하여 플로우 관리나 제어 관리에서 외부 DB를 연동할 수 있습니다."); - - } catch (error: any) { - console.error("\n❌ 오류 발생:", error.message); - console.error("상세 정보:", error); - throw error; - } finally { - await internalPool.end(); - } -} - -// 스크립트 실행 -testDigitalTwinDb() - .then(() => { - console.log("\n🎉 스크립트 완료"); - process.exit(0); - }) - .catch((error) => { - console.error("\n💥 스크립트 실패:", error); - process.exit(1); - }); - - diff --git a/backend-node/scripts/test-template-creation.js b/backend-node/scripts/test-template-creation.js deleted file mode 100644 index a4879cbc..00000000 --- a/backend-node/scripts/test-template-creation.js +++ /dev/null @@ -1,121 +0,0 @@ -const { PrismaClient } = require("@prisma/client"); - -const prisma = new PrismaClient(); - -async function testTemplateCreation() { - console.log("🧪 템플릿 생성 테스트 시작..."); - - try { - // 1. 테이블 존재 여부 확인 - console.log("1. 템플릿 테이블 존재 여부 확인 중..."); - - try { - const count = await prisma.template_standards.count(); - console.log(`✅ template_standards 테이블 발견 (현재 ${count}개 레코드)`); - } catch (error) { - if (error.code === "P2021") { - console.log("❌ template_standards 테이블이 존재하지 않습니다."); - console.log("👉 데이터베이스 마이그레이션이 필요합니다."); - return; - } - throw error; - } - - // 2. 샘플 템플릿 생성 테스트 - console.log("2. 샘플 템플릿 생성 중..."); - - const sampleTemplate = { - template_code: "test-button-" + Date.now(), - template_name: "테스트 버튼", - template_name_eng: "Test Button", - description: "테스트용 버튼 템플릿", - category: "button", - icon_name: "mouse-pointer", - default_size: { - width: 80, - height: 36, - }, - layout_config: { - components: [ - { - type: "widget", - widgetType: "button", - label: "테스트 버튼", - position: { x: 0, y: 0 }, - size: { width: 80, height: 36 }, - style: { - backgroundColor: "#3b82f6", - color: "#ffffff", - border: "none", - borderRadius: "6px", - }, - }, - ], - }, - sort_order: 999, - is_active: "Y", - is_public: "Y", - company_code: "*", - created_by: "test", - updated_by: "test", - }; - - const created = await prisma.template_standards.create({ - data: sampleTemplate, - }); - - console.log("✅ 샘플 템플릿 생성 성공:", created.template_code); - - // 3. 생성된 템플릿 조회 테스트 - console.log("3. 템플릿 조회 테스트 중..."); - - const retrieved = await prisma.template_standards.findUnique({ - where: { template_code: created.template_code }, - }); - - if (retrieved) { - console.log("✅ 템플릿 조회 성공:", retrieved.template_name); - console.log( - "📄 Layout Config:", - JSON.stringify(retrieved.layout_config, null, 2) - ); - } - - // 4. 카테고리 목록 조회 테스트 - console.log("4. 카테고리 목록 조회 테스트 중..."); - - const categories = await prisma.template_standards.findMany({ - where: { is_active: "Y" }, - select: { category: true }, - distinct: ["category"], - }); - - console.log( - "✅ 발견된 카테고리:", - categories.map((c) => c.category) - ); - - // 5. 테스트 데이터 정리 - console.log("5. 테스트 데이터 정리 중..."); - - await prisma.template_standards.delete({ - where: { template_code: created.template_code }, - }); - - console.log("✅ 테스트 데이터 정리 완료"); - - console.log("🎉 모든 테스트 통과!"); - } catch (error) { - console.error("❌ 테스트 실패:", error); - console.error("📋 상세 정보:", { - message: error.message, - code: error.code, - stack: error.stack?.split("\n").slice(0, 5), - }); - } finally { - await prisma.$disconnect(); - } -} - -// 스크립트 실행 -testTemplateCreation(); diff --git a/backend-node/scripts/verify-migration.js b/backend-node/scripts/verify-migration.js deleted file mode 100644 index 5c3b9175..00000000 --- a/backend-node/scripts/verify-migration.js +++ /dev/null @@ -1,86 +0,0 @@ -/** - * 마이그레이션 검증 스크립트 - */ - -const { Pool } = require('pg'); - -const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:ph0909!!@39.117.244.52:11132/plm'; - -const pool = new Pool({ - connectionString: databaseUrl, -}); - -async function verifyMigration() { - const client = await pool.connect(); - - try { - console.log('🔍 마이그레이션 결과 검증 중...\n'); - - // 전체 요소 수 - const total = await client.query(` - SELECT COUNT(*) as count FROM dashboard_elements - `); - - // 새로운 subtype별 개수 - const mapV2 = await client.query(` - SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'map-summary-v2' - `); - - const chart = await client.query(` - SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'chart' - `); - - const listV2 = await client.query(` - SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'list-v2' - `); - - const metricV2 = await client.query(` - SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'custom-metric-v2' - `); - - const alertV2 = await client.query(` - SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype = 'risk-alert-v2' - `); - - // 테스트 subtype 남아있는지 확인 - const remaining = await client.query(` - SELECT COUNT(*) as count FROM dashboard_elements WHERE element_subtype LIKE '%-test%' - `); - - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log('📊 마이그레이션 결과 요약'); - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log(`전체 요소 수: ${total.rows[0].count}`); - console.log(`map-summary-v2: ${mapV2.rows[0].count}`); - console.log(`chart: ${chart.rows[0].count}`); - console.log(`list-v2: ${listV2.rows[0].count}`); - console.log(`custom-metric-v2: ${metricV2.rows[0].count}`); - console.log(`risk-alert-v2: ${alertV2.rows[0].count}`); - console.log(''); - - if (parseInt(remaining.rows[0].count) > 0) { - console.log(`⚠️ 테스트 subtype이 ${remaining.rows[0].count}개 남아있습니다!`); - } else { - console.log('✅ 모든 테스트 subtype이 정상적으로 변경되었습니다!'); - } - - console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); - console.log(''); - console.log('🎉 마이그레이션이 성공적으로 완료되었습니다!'); - console.log(''); - console.log('다음 단계:'); - console.log('1. 프론트엔드 애플리케이션을 새로고침하세요'); - console.log('2. 대시보드를 열어 위젯이 정상적으로 작동하는지 확인하세요'); - console.log('3. 문제가 발생하면 백업에서 복원하세요'); - console.log(''); - - } catch (error) { - console.error('❌ 오류 발생:', error.message); - } finally { - client.release(); - await pool.end(); - } -} - -verifyMigration(); - diff --git a/backend-node/src/config/environment.ts b/backend-node/src/config/environment.ts index e350642d..558f3f07 100644 --- a/backend-node/src/config/environment.ts +++ b/backend-node/src/config/environment.ts @@ -93,7 +93,7 @@ const config: Config = { // JWT 설정 jwt: { - secret: process.env.JWT_SECRET || "ilshin-plm-super-secret-jwt-key-2024", + secret: process.env.JWT_SECRET || "change-this-jwt-secret-in-env", expiresIn: process.env.JWT_EXPIRES_IN || "24h", refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d", }, diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index 3a1aeec4..f7d9dfb4 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -286,6 +286,11 @@ export async function create(req: AuthenticatedRequest, res: Response) { received_qty = CAST( COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text ), + balance_qty = CAST( + COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) + - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + - $1 AS text + ), updated_date = NOW() WHERE id = $2 AND company_code = $3`, [item.inbound_qty || 0, item.source_id, companyCode] @@ -783,7 +788,7 @@ export async function getShipments(req: AuthenticatedRequest, res: Response) { export async function getItems(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; - const { keyword, page, pageSize } = req.query; + const { keyword, page, pageSize, division } = req.query; const currentPage = Math.max(1, Number(page) || 1); const limit = Math.min(500, Math.max(1, Number(pageSize) || 20)); const offset = (currentPage - 1) * limit; @@ -800,6 +805,12 @@ export async function getItems(req: AuthenticatedRequest, res: Response) { paramIdx++; } + if (division) { + conditions.push(`division ILIKE $${paramIdx}`); + params.push(`%${division}%`); + paramIdx++; + } + const whereClause = conditions.join(" AND "); const pool = getPool(); 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/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index dfe685ff..a95b08f1 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -7,9 +7,21 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; import { numberingRuleService } from "../services/numberingRuleService"; +// 자동 마이그레이션: work_instruction_detail에 routing_version_id 컬럼 추가 +let _migrationDone = false; +async function ensureDetailRoutingColumn() { + if (_migrationDone) return; + try { + const pool = getPool(); + await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS routing_version_id VARCHAR(500)"); + _migrationDone = true; + } catch { /* 이미 존재하거나 권한 문제 시 무시 */ } +} + // ─── 작업지시 목록 조회 (detail 기준 행 반환) ─── export async function getList(req: AuthenticatedRequest, res: Response) { try { + await ensureDetailRoutingColumn(); const companyCode = req.user!.companyCode; const { dateFrom, dateTo, status, progressStatus, keyword } = req.query; @@ -72,6 +84,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) { d.part_code, d.source_table, d.source_id, + d.routing_version_id AS detail_routing_version_id, COALESCE(itm.item_name, '') AS item_name, COALESCE(itm.size, '') AS item_spec, COALESCE(e.equipment_name, '') AS equipment_name, @@ -131,6 +144,7 @@ export async function previewNextNo(req: AuthenticatedRequest, res: Response) { // ─── 작업지시 저장 (신규/수정) ─── export async function save(req: AuthenticatedRequest, res: Response) { try { + await ensureDetailRoutingColumn(); const companyCode = req.user!.companyCode; const userId = req.user!.userId; const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body; @@ -175,8 +189,8 @@ export async function save(req: AuthenticatedRequest, res: Response) { for (const item of items) { await client.query( - `INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,item_number,qty,remark,source_table,source_id,part_code,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9)`, - [companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", userId] + `INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,NOW(),$10)`, + [companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", item.routing||null, userId] ); } 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/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index bc65822c..3bcf4f3d 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -1195,6 +1195,10 @@ export class DynamicFormService { const updatedRecord = Array.isArray(result) ? result[0] : result; + if (!updatedRecord) { + throw new Error(`업데이트 대상 레코드를 찾을 수 없습니다. (id: ${id}, 테이블: ${tableName})`); + } + // 🔥 조건부 연결 실행 (UPDATE 트리거) try { if (company_code) { 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/services/riskAlertService.ts b/backend-node/src/services/riskAlertService.ts index 03a3fdf1..923bed52 100644 --- a/backend-node/src/services/riskAlertService.ts +++ b/backend-node/src/services/riskAlertService.ts @@ -202,7 +202,7 @@ export class RiskAlertService { } // 2순위: 한국도로공사 API (현재 차단됨) - const exwayApiKey = process.env.EXWAY_API_KEY || '7820214492'; + const exwayApiKey = process.env.EXWAY_API_KEY || ''; try { const url = 'https://data.ex.co.kr/openapi/business/trafficFcst'; @@ -321,7 +321,7 @@ export class RiskAlertService { } // 2순위: 한국도로공사 API - const exwayApiKey = process.env.EXWAY_API_KEY || '7820214492'; + const exwayApiKey = process.env.EXWAY_API_KEY || ''; try { const url = 'https://data.ex.co.kr/openapi/business/trafficFcst'; diff --git a/backend-node/src/tests/env.setup.ts b/backend-node/src/tests/env.setup.ts index 55263a9a..85825aeb 100644 --- a/backend-node/src/tests/env.setup.ts +++ b/backend-node/src/tests/env.setup.ts @@ -6,8 +6,7 @@ process.env.NODE_ENV = "test"; // 실제 DB 연결을 위해 운영 데이터베이스 사용 (읽기 전용 테스트만 수행) process.env.DATABASE_URL = - process.env.TEST_DATABASE_URL || - "postgresql://postgres:ph0909!!@39.117.244.52:11132/plm"; + process.env.TEST_DATABASE_URL || ""; process.env.JWT_SECRET = "test-jwt-secret-key-for-testing-only"; process.env.PORT = "3001"; process.env.DEBUG = "true"; // 테스트 시 디버그 로그 활성화 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/db/migrations/RUN_MIGRATION_1004.md b/db/migrations/RUN_MIGRATION_1004.md new file mode 100644 index 00000000..fb1a0d11 --- /dev/null +++ b/db/migrations/RUN_MIGRATION_1004.md @@ -0,0 +1,17 @@ +# Migration 1004: report_query에 visual_data_source 컬럼 추가 + +## 목적 +비주얼 데이터 소스 빌더의 UI 설정 원본을 저장하여, 다시 편집할 때 복원할 수 있도록 한다. + +## 실행 SQL + +```sql +ALTER TABLE report_query ADD COLUMN IF NOT EXISTS visual_data_source JSONB DEFAULT NULL; +COMMENT ON COLUMN report_query.visual_data_source IS '비주얼 데이터 소스 빌더 UI 설정 원본 (다시 편집할 때 복원용)'; +``` + +## 롤백 SQL + +```sql +ALTER TABLE report_query DROP COLUMN IF EXISTS visual_data_source; +``` diff --git a/docker-compose.backend.win.yml b/docker-compose.backend.win.yml index 35dbf42a..72e0d987 100644 --- a/docker-compose.backend.win.yml +++ b/docker-compose.backend.win.yml @@ -12,10 +12,10 @@ services: environment: - NODE_ENV=development - PORT=8080 - - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm - - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - - JWT_EXPIRES_IN=24h - - ENCRYPTION_KEY=ilshin-plm-encryption-key-2024-secure-32bytes + - DATABASE_URL=${DATABASE_URL} + - JWT_SECRET=${JWT_SECRET} + - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} - CORS_ORIGIN=http://localhost:9771 - CORS_CREDENTIALS=true - LOG_LEVEL=debug diff --git a/docker/deploy/docker-compose.yml b/docker/deploy/docker-compose.yml index efd1b961..f653f617 100644 --- a/docker/deploy/docker-compose.yml +++ b/docker/deploy/docker-compose.yml @@ -12,15 +12,15 @@ services: NODE_ENV: production PORT: "3001" HOST: 0.0.0.0 - DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor - JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024 - JWT_EXPIRES_IN: 24h - CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com + DATABASE_URL: ${DATABASE_URL} + JWT_SECRET: ${JWT_SECRET} + JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h} + CORS_ORIGIN: ${CORS_ORIGIN:-https://v1.vexplor.com,https://api.vexplor.com} CORS_CREDENTIALS: "true" LOG_LEVEL: info - ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure - KMA_API_KEY: ogdXr2e9T4iHV69nvV-IwA - ITS_API_KEY: d6b9befec3114d648284674b8fddcc32 + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + KMA_API_KEY: ${KMA_API_KEY} + ITS_API_KEY: ${ITS_API_KEY} EXPRESSWAY_API_KEY: ${EXPRESSWAY_API_KEY:-} volumes: - backend_uploads:/app/uploads diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index 4d862d9e..5002e813 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -12,15 +12,15 @@ services: environment: - NODE_ENV=development - PORT=8080 - - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm - - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - - JWT_EXPIRES_IN=24h + - DATABASE_URL=${DATABASE_URL} + - JWT_SECRET=${JWT_SECRET} + - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h} - CORS_ORIGIN=http://localhost:9771 - CORS_CREDENTIALS=true - LOG_LEVEL=debug - - ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure - - KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA - - ITS_API_KEY=d6b9befec3114d648284674b8fddcc32 + - ENCRYPTION_KEY=${ENCRYPTION_KEY} + - KMA_API_KEY=${KMA_API_KEY} + - ITS_API_KEY=${ITS_API_KEY} - EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-} volumes: - ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영 diff --git a/docker/prod/docker-compose.backend.prod.yml b/docker/prod/docker-compose.backend.prod.yml index a3327ea1..06a769f2 100644 --- a/docker/prod/docker-compose.backend.prod.yml +++ b/docker/prod/docker-compose.backend.prod.yml @@ -13,15 +13,15 @@ services: - NODE_ENV=production - PORT=8080 - HOST=0.0.0.0 # 모든 인터페이스에서 바인딩 - - DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm - - JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024 - - JWT_EXPIRES_IN=24h - - CORS_ORIGIN=http://192.168.0.70:5555,http://39.117.244.52:5555,http://localhost:9771 + - DATABASE_URL=${DATABASE_URL} + - JWT_SECRET=${JWT_SECRET} + - JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h} + - CORS_ORIGIN=${CORS_ORIGIN:-http://192.168.0.70:5555,http://39.117.244.52:5555,http://localhost:9771} - CORS_CREDENTIALS=true - LOG_LEVEL=info - - ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure - - KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA - - ITS_API_KEY=d6b9befec3114d648284674b8fddcc32 + - ENCRYPTION_KEY=${ENCRYPTION_KEY} + - KMA_API_KEY=${KMA_API_KEY} + - ITS_API_KEY=${ITS_API_KEY} - EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-} restart: unless-stopped healthcheck: diff --git a/docs/DDD1542/본서버_개발서버_마이그레이션_가이드.md b/docs/DDD1542/본서버_개발서버_마이그레이션_가이드.md index b7a0e353..dc71bd2d 100644 --- a/docs/DDD1542/본서버_개발서버_마이그레이션_가이드.md +++ b/docs/DDD1542/본서버_개발서버_마이그레이션_가이드.md @@ -23,7 +23,7 @@ screen_layouts (V1) screen_layouts_v2 (V2) docker exec pms-backend-mac node -e ' const { Pool } = require("pg"); const pool = new Pool({ - connectionString: "postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm?sslmode=disable", + connectionString: "postgresql://postgres:$DB_PASSWORD@211.115.91.141:11134/plm?sslmode=disable", ssl: false }); // 쿼리 실행 diff --git a/docs/POP_화면_배포서버_마이그레이션_가이드.md b/docs/POP_화면_배포서버_마이그레이션_가이드.md index 8711c60e..6ac2b777 100644 --- a/docs/POP_화면_배포서버_마이그레이션_가이드.md +++ b/docs/POP_화면_배포서버_마이그레이션_가이드.md @@ -177,7 +177,7 @@ CREATE TABLE backup_20260323_screen_group_screens AS ### STEP 1: 누락 테이블 생성 (배포 DB에서 psql 실행) ```bash -PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm +PGPASSWORD='$DB_PASSWORD' psql -h 211.115.91.141 -p 11134 -U postgres -d plm ``` #### 1-1. work_order_process @@ -366,7 +366,7 @@ COPY ( ) TO STDOUT WITH CSV HEADER" > /tmp/ttc_export.csv # 배포 DB에 삽입 (충돌 시 무시) -PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " +PGPASSWORD='$DB_PASSWORD' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " COPY table_type_columns FROM STDIN WITH CSV HEADER ON CONFLICT DO NOTHING" < /tmp/ttc_export.csv ``` @@ -386,7 +386,7 @@ COPY ( ) TO STDOUT WITH CSV HEADER" > /tmp/screen_def.csv # 배포에 삽입 -PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " +PGPASSWORD='$DB_PASSWORD' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " COPY screen_definitions FROM STDIN WITH CSV HEADER ON CONFLICT DO NOTHING" < /tmp/screen_def.csv @@ -400,7 +400,7 @@ COPY ( ) TO STDOUT WITH CSV HEADER" > /tmp/screen_layouts.csv # 배포에 삽입 -PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " +PGPASSWORD='$DB_PASSWORD' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " COPY screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by) FROM STDIN WITH CSV HEADER ON CONFLICT (screen_id, company_code) DO NOTHING" < /tmp/screen_layouts.csv @@ -414,7 +414,7 @@ COPY ( ) TO STDOUT WITH CSV HEADER" > /tmp/screen_groups.csv # 배포에 삽입 -PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " +PGPASSWORD='$DB_PASSWORD' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " COPY screen_groups FROM STDIN WITH CSV HEADER ON CONFLICT DO NOTHING" < /tmp/screen_groups.csv @@ -427,7 +427,7 @@ COPY ( ) TO STDOUT WITH CSV HEADER" > /tmp/screen_group_screens.csv # 배포에 삽입 -PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " +PGPASSWORD='$DB_PASSWORD' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c " COPY screen_group_screens FROM STDIN WITH CSV HEADER ON CONFLICT DO NOTHING" < /tmp/screen_group_screens.csv diff --git a/docs/leeheejin/리스크알림_API키_발급가이드.md b/docs/leeheejin/리스크알림_API키_발급가이드.md index e2a33761..a19e4eb9 100644 --- a/docs/leeheejin/리스크알림_API키_발급가이드.md +++ b/docs/leeheejin/리스크알림_API키_발급가이드.md @@ -13,7 +13,7 @@ 현재 `.env`에 설정된 키: ```bash -KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA +KMA_API_KEY=${KMA_API_KEY} ``` **사용 API:** @@ -105,7 +105,7 @@ nano .env ```bash # 기상청 API Hub 키 (기존 - 예특보 활용신청 완료 시 사용) -KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA +KMA_API_KEY=${KMA_API_KEY} # 국토교통부 도로교통 API 키 (활용신청 완료 시 추가) MOLIT_TRAFFIC_API_KEY=여기에_발급받은_교통사고_API_인증키_붙여넣기 diff --git a/docs/leeheejin/메일관리_기능_리스트.md b/docs/leeheejin/메일관리_기능_리스트.md index 9bed9d5b..6b341983 100644 --- a/docs/leeheejin/메일관리_기능_리스트.md +++ b/docs/leeheejin/메일관리_기능_리스트.md @@ -222,7 +222,7 @@ uploads/ ### 필수 환경변수 ```bash -ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure +ENCRYPTION_KEY=${ENCRYPTION_KEY} ``` ### 필수 디렉토리 diff --git a/docs/leeheejin/메일관리_시스템_구현_계획서.md b/docs/leeheejin/메일관리_시스템_구현_계획서.md index 31cd2ee9..06e67327 100644 --- a/docs/leeheejin/메일관리_시스템_구현_계획서.md +++ b/docs/leeheejin/메일관리_시스템_구현_계획서.md @@ -401,7 +401,7 @@ MailDesigner 통합 및 템플릿 저장/불러오기 ### 필수 환경변수 ```bash # docker/dev/docker-compose.backend.mac.yml -ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure +ENCRYPTION_KEY=${ENCRYPTION_KEY} ``` ### 저장 디렉토리 생성 diff --git a/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx index 045679e5..80995df0 100644 --- a/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/customer/page.tsx @@ -457,6 +457,8 @@ export default function CustomerManagementPage() { try { const filters: any[] = []; if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); + // 영업관리/판매품 division 필터 (다중값 contains 매칭) + filters.push({ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" }); const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 50, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, diff --git a/frontend/app/(main)/COMPANY_29/sales/order/page.tsx b/frontend/app/(main)/COMPANY_29/sales/order/page.tsx index 7f23909c..0b889292 100644 --- a/frontend/app/(main)/COMPANY_29/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/order/page.tsx @@ -326,7 +326,7 @@ export default function SalesOrderPage() { const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || ""; const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || ""; const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || ""; - setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, manager_id: user?.userId || "" }); + setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, manager_id: user?.userId || "", order_date: new Date().toISOString().split("T")[0] }); setDetailRows([]); setDeliveryOptions([]); setIsEditMode(false); @@ -474,16 +474,9 @@ export default function SalesOrderPage() { if (itemSearchKeyword) { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } + // division 필터는 다중값(쉼표 구분)이므로 contains로 부분 매칭 if (itemSearchDivision !== "all") { - filters.push({ columnName: "division", operator: "equals", value: itemSearchDivision }); - } else { - // 기본: 영업관련 division만 (판매품, 제품, 영업관리 등) - const salesDivCodes = (categoryOptions["item_division"] || []) - .filter((o) => ["판매품", "제품", "영업관리"].some((label) => o.label.includes(label))) - .map((o) => o.code); - if (salesDivCodes.length > 0) { - filters.push({ columnName: "division", operator: "in", value: salesDivCodes }); - } + filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision }); } const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: p, size: s, diff --git a/frontend/app/(main)/COMPANY_29/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_29/sales/sales-item/page.tsx index 5da2b3b5..a2fcd9a9 100644 --- a/frontend/app/(main)/COMPANY_29/sales/sales-item/page.tsx +++ b/frontend/app/(main)/COMPANY_29/sales/sales-item/page.tsx @@ -166,8 +166,8 @@ export default function SalesItemPage() { try { const filters: { columnName: string; operator: string; value: any }[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); - // 판매품목 division 필터 (다중값 컬럼이므로 contains로 매칭) - filters.push({ columnName: "division", operator: "contains", value: "CAT_DIV_SALES" }); + // 판매품목/영업관리 division 필터 (다중값 컬럼이므로 contains로 매칭) + filters.push({ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" }); const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, diff --git a/frontend/app/(main)/COMPANY_7/equipment/info/page.tsx b/frontend/app/(main)/COMPANY_7/equipment/info/page.tsx index 9bb5fede..6c843483 100644 --- a/frontend/app/(main)/COMPANY_7/equipment/info/page.tsx +++ b/frontend/app/(main)/COMPANY_7/equipment/info/page.tsx @@ -105,9 +105,11 @@ export default function EquipmentInfoPage() { const [inspectionModalOpen, setInspectionModalOpen] = useState(false); const [inspectionForm, setInspectionForm] = useState>({}); + const [inspectionContinuous, setInspectionContinuous] = useState(false); const [consumableModalOpen, setConsumableModalOpen] = useState(false); const [consumableForm, setConsumableForm] = useState>({}); + const [consumableContinuous, setConsumableContinuous] = useState(false); const [consumableItemOptions, setConsumableItemOptions] = useState([]); // 점검항목 복사 @@ -294,7 +296,13 @@ export default function EquipmentInfoPage() { await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, { ...inspectionForm, equipment_code: selectedEquip?.equipment_code, }); - toast.success("추가되었습니다."); setInspectionModalOpen(false); refreshRight(); + toast.success("추가되었습니다."); + if (inspectionContinuous) { + setInspectionForm({}); + } else { + setInspectionModalOpen(false); + } + refreshRight(); } catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); } }; @@ -347,7 +355,13 @@ export default function EquipmentInfoPage() { await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, { ...consumableForm, equipment_code: selectedEquip?.equipment_code, }); - toast.success("추가되었습니다."); setConsumableModalOpen(false); refreshRight(); + toast.success("추가되었습니다."); + if (consumableContinuous) { + setConsumableForm({}); + } else { + setConsumableModalOpen(false); + } + refreshRight(); } catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); } }; @@ -609,8 +623,16 @@ export default function EquipmentInfoPage() {
setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" />
- - + + +
+ + +
+
@@ -659,8 +681,16 @@ export default function EquipmentInfoPage() { setConsumableForm((p) => ({ ...p, image_path: v }))} tableName={CONSUMABLE_TABLE} columnName="image_path" /> - - + + +
+ + +
+
diff --git a/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx index 6e493a5c..4222a23d 100644 --- a/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx +++ b/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx @@ -42,6 +42,7 @@ import { ChevronsRight, } from "lucide-react"; import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; import { getReceivingList, createReceiving, @@ -140,13 +141,23 @@ export default function ReceivingPage() { const [sourcePageSize, setSourcePageSize] = useState(20); const [sourceTotalCount, setSourceTotalCount] = useState(0); - // 날짜 초기화 + // 구매관리 division 코드 (라벨 기준 조회) + const [purchaseDivisionCode, setPurchaseDivisionCode] = useState(""); + + // 날짜 초기화 + 구매관리 division 코드 로드 useEffect(() => { const today = new Date(); const threeMonthsAgo = new Date(today); threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); setSearchDateTo(today.toISOString().split("T")[0]); + + // division 카테고리에서 "구매관리" 라벨의 코드 조회 + apiClient.get("/table-categories/item_info/division/values").then((res) => { + const vals = res.data?.data || []; + const found = vals.find((v: any) => (v.value_label || v.label) === "구매관리"); + if (found) setPurchaseDivisionCode(found.value_code || found.code); + }).catch(() => {}); }, []); // 목록 조회 @@ -243,7 +254,7 @@ export default function ReceivingPage() { setSourceTotalCount(res.totalCount || 0); } } else { - const res = await getItemSources(params); + const res = await getItemSources({ ...params, division: purchaseDivisionCode || undefined }); if (res.success) { setItems(res.data); setSourceTotalCount(res.totalCount || 0); diff --git a/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx index 6f042bd0..d9167dcb 100644 --- a/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx +++ b/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx @@ -11,7 +11,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { Loader2, Save, RotateCcw, Plus, Trash2, Pencil, ClipboardCheck, - ChevronRight, GripVertical, AlertCircle, + GripVertical, AlertCircle, ClipboardList, } from "lucide-react"; import { toast } from "sonner"; import { @@ -30,9 +30,9 @@ interface WorkStandardEditModalProps { } const PHASES = [ - { key: "PRE", label: "사전작업" }, - { key: "MAIN", label: "본작업" }, - { key: "POST", label: "후작업" }, + { key: "PRE", label: "작업 전 (Pre-Work)" }, + { key: "IN", label: "작업 중 (In-Work)" }, + { key: "POST", label: "작업 후 (Post-Work)" }, ]; const DETAIL_TYPES = [ @@ -47,6 +47,53 @@ const DETAIL_TYPES = [ { value: "material_input", label: "자재투입" }, ]; +const INPUT_TYPES = [ + { value: "text", label: "텍스트" }, + { value: "number", label: "숫자" }, + { value: "date", label: "날짜" }, + { value: "textarea", label: "장문 텍스트" }, +]; + +const UNIT_OPTIONS = [ + "mm", "cm", "m", "μm", "℃", "℉", "bar", "Pa", "MPa", "psi", + "RPM", "kg", "N", "N·m", "m/s", "m/min", "A", "V", "kW", "%", + "L/min", "Hz", "dB", "ea", "g", "mg", "ml", "L", +]; + +const getDetailTypeLabel = (type: string) => + DETAIL_TYPES.find(d => d.value === type)?.label || type; + +const getContentSummary = (detail: WIWorkItemDetail): string => { + const type = detail.detail_type; + if (type === "inspection") { + const parts = [detail.content]; + if (detail.inspection_method) parts.push(`[${detail.inspection_method}]`); + if (detail.base_value) { + parts.push(`(기준: ${detail.base_value}${detail.tolerance ? ` ±${detail.tolerance}` : ""} ${detail.unit || ""})`); + } + return parts.join(" "); + } + if (type === "procedure" && detail.duration_minutes) { + return `${detail.content} (${detail.duration_minutes}분)`; + } + if (type === "input" && detail.input_type) { + const typeMap: Record = { text: "텍스트", number: "숫자", date: "날짜", textarea: "장문" }; + return `${detail.content} [${typeMap[detail.input_type] || detail.input_type}]`; + } + if (type === "lookup") return "품목 등록 문서 (자동 연동)"; + if (type === "equip_inspection") return detail.content || "설비점검"; + if (type === "equip_condition") { + const parts = [detail.content]; + if (detail.condition_base_value) { + parts.push(`(기준: ${detail.condition_base_value}${detail.condition_tolerance ? ` ±${detail.condition_tolerance}` : ""} ${detail.condition_unit || ""})`); + } + return parts.join(" "); + } + if (type === "production_result") return "작업수량 / 불량수량 / 양품수량"; + if (type === "material_input") return "BOM 구성 자재 (자동 연동)"; + return detail.content || "-"; +}; + export function WorkStandardEditModal({ open, onClose, @@ -61,20 +108,22 @@ export function WorkStandardEditModal({ const [processes, setProcesses] = useState([]); const [isCustom, setIsCustom] = useState(false); const [selectedProcessIdx, setSelectedProcessIdx] = useState(0); - const [selectedPhase, setSelectedPhase] = useState("PRE"); - const [selectedWorkItemId, setSelectedWorkItemId] = useState(null); + const [selectedWorkItemIdByPhase, setSelectedWorkItemIdByPhase] = useState>({}); const [dirty, setDirty] = useState(false); - // 작업항목 추가 모달 + // 작업항목 추가/수정 모달 const [addItemOpen, setAddItemOpen] = useState(false); + const [addItemPhase, setAddItemPhase] = useState("PRE"); const [addItemTitle, setAddItemTitle] = useState(""); const [addItemRequired, setAddItemRequired] = useState("Y"); + const [editingWorkItem, setEditingWorkItem] = useState(null); - // 상세 추가 모달 - const [addDetailOpen, setAddDetailOpen] = useState(false); - const [addDetailType, setAddDetailType] = useState("checklist"); - const [addDetailContent, setAddDetailContent] = useState(""); - const [addDetailRequired, setAddDetailRequired] = useState("N"); + // 상세 추가/수정 모달 + const [detailModalOpen, setDetailModalOpen] = useState(false); + const [detailModalMode, setDetailModalMode] = useState<"add" | "edit">("add"); + const [detailModalPhase, setDetailModalPhase] = useState("PRE"); + const [detailFormData, setDetailFormData] = useState>({}); + const [editingDetail, setEditingDetail] = useState(null); // 데이터 로드 const loadData = useCallback(async () => { @@ -86,8 +135,7 @@ export function WorkStandardEditModal({ setProcesses(res.data.processes); setIsCustom(res.data.isCustom); setSelectedProcessIdx(0); - setSelectedPhase("PRE"); - setSelectedWorkItemId(null); + setSelectedWorkItemIdByPhase({}); setDirty(false); } } catch (err) { @@ -102,15 +150,18 @@ export function WorkStandardEditModal({ }, [open, loadData]); const currentProcess = processes[selectedProcessIdx] || null; - const currentWorkItems = useMemo(() => { - if (!currentProcess) return []; - return currentProcess.workItems.filter(wi => wi.work_phase === selectedPhase); - }, [currentProcess, selectedPhase]); - const selectedWorkItem = useMemo(() => { - if (!selectedWorkItemId || !currentProcess) return null; - return currentProcess.workItems.find(wi => wi.id === selectedWorkItemId) || null; - }, [selectedWorkItemId, currentProcess]); + // Phase별 작업항목 그룹핑 (MAIN을 IN으로 매핑) + const workItemsByPhase = useMemo(() => { + if (!currentProcess) return {}; + const map: Record = {}; + for (const phase of PHASES) { + map[phase.key] = currentProcess.workItems.filter( + wi => wi.work_phase === phase.key || (phase.key === "IN" && wi.work_phase === "MAIN") + ); + } + return map; + }, [currentProcess]); // 커스텀 복사 확인 후 수정 const ensureCustom = useCallback(async () => { @@ -128,40 +179,76 @@ export function WorkStandardEditModal({ return false; }, [isCustom, workInstructionNo, routingVersionId, loadData]); - // 작업항목 추가 - const handleAddWorkItem = useCallback(async () => { + // 작업항목 추가 모달 열기 + const openAddWorkItem = useCallback((phaseKey: string) => { + setAddItemPhase(phaseKey); + setAddItemTitle(""); + setAddItemRequired("Y"); + setEditingWorkItem(null); + setAddItemOpen(true); + }, []); + + // 작업항목 수정 모달 열기 + const openEditWorkItem = useCallback((item: WIWorkItem) => { + setAddItemPhase(item.work_phase); + setAddItemTitle(item.title); + setAddItemRequired(item.is_required); + setEditingWorkItem(item); + setAddItemOpen(true); + }, []); + + // 작업항목 추가/수정 처리 + const handleSaveWorkItem = useCallback(async () => { if (!addItemTitle.trim()) { toast.error("제목을 입력하세요"); return; } const ok = await ensureCustom(); if (!ok || !currentProcess) return; - const newItem: WIWorkItem = { - id: `temp-${Date.now()}`, - routing_detail_id: currentProcess.routing_detail_id, - work_phase: selectedPhase, - title: addItemTitle.trim(), - is_required: addItemRequired, - sort_order: currentWorkItems.length + 1, - details: [], - }; - - setProcesses(prev => { - const next = [...prev]; - next[selectedProcessIdx] = { - ...next[selectedProcessIdx], - workItems: [...next[selectedProcessIdx].workItems, newItem], + if (editingWorkItem) { + // 수정 + setProcesses(prev => { + const next = [...prev]; + const workItems = [...next[selectedProcessIdx].workItems]; + const idx = workItems.findIndex(wi => wi.id === editingWorkItem.id); + if (idx >= 0) { + workItems[idx] = { ...workItems[idx], title: addItemTitle.trim(), is_required: addItemRequired }; + } + next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems }; + return next; + }); + } else { + // 추가 + const phaseKey = addItemPhase === "IN" ? "IN" : addItemPhase; + const phaseItems = currentProcess.workItems.filter( + wi => wi.work_phase === phaseKey || (phaseKey === "IN" && wi.work_phase === "MAIN") + ); + const newItem: WIWorkItem = { + id: `temp-${Date.now()}`, + routing_detail_id: currentProcess.routing_detail_id, + work_phase: phaseKey, + title: addItemTitle.trim(), + is_required: addItemRequired, + sort_order: phaseItems.length + 1, + details: [], }; - return next; - }); - setAddItemTitle(""); - setAddItemRequired("Y"); + setProcesses(prev => { + const next = [...prev]; + next[selectedProcessIdx] = { + ...next[selectedProcessIdx], + workItems: [...next[selectedProcessIdx].workItems, newItem], + }; + return next; + }); + + setSelectedWorkItemIdByPhase(prev => ({ ...prev, [addItemPhase]: newItem.id! })); + } + setAddItemOpen(false); setDirty(true); - setSelectedWorkItemId(newItem.id!); - }, [addItemTitle, addItemRequired, ensureCustom, currentProcess, selectedPhase, currentWorkItems, selectedProcessIdx]); + }, [addItemTitle, addItemRequired, addItemPhase, ensureCustom, currentProcess, selectedProcessIdx, editingWorkItem]); // 작업항목 삭제 - const handleDeleteWorkItem = useCallback(async (id: string) => { + const handleDeleteWorkItem = useCallback(async (id: string, phaseKey: string) => { const ok = await ensureCustom(); if (!ok) return; @@ -173,37 +260,82 @@ export function WorkStandardEditModal({ }; return next; }); - if (selectedWorkItemId === id) setSelectedWorkItemId(null); + if (selectedWorkItemIdByPhase[phaseKey] === id) { + setSelectedWorkItemIdByPhase(prev => ({ ...prev, [phaseKey]: null })); + } setDirty(true); - }, [ensureCustom, selectedProcessIdx, selectedWorkItemId]); + }, [ensureCustom, selectedProcessIdx, selectedWorkItemIdByPhase]); - // 상세 추가 - const handleAddDetail = useCallback(async () => { - if (!addDetailContent.trim() && addDetailType !== "production_result" && addDetailType !== "material_input") { + // 상세 추가 모달 열기 + const openAddDetail = useCallback((phaseKey: string) => { + setDetailModalPhase(phaseKey); + setDetailModalMode("add"); + setEditingDetail(null); + setDetailFormData({ + detail_type: DETAIL_TYPES[0].value, + content: "", + is_required: "Y", + }); + setDetailModalOpen(true); + }, []); + + // 상세 수정 모달 열기 + const openEditDetail = useCallback((detail: WIWorkItemDetail, phaseKey: string) => { + setDetailModalPhase(phaseKey); + setDetailModalMode("edit"); + setEditingDetail(detail); + setDetailFormData({ ...detail }); + setDetailModalOpen(true); + }, []); + + // 상세 추가/수정 처리 + const handleSaveDetail = useCallback(async () => { + const type = detailFormData.detail_type || ""; + if (!type) return; + + // 유효성 검사 (내용이 필요한 유형) + const needsContent = ["checklist", "procedure", "input", "equip_condition"]; + if (needsContent.includes(type) && !detailFormData.content?.trim()) { toast.error("내용을 입력하세요"); return; } - if (!selectedWorkItemId) return; + if (type === "inspection" && !detailFormData.content?.trim()) { + toast.error("검사 항목명을 입력하세요"); + return; + } + + const workItemId = selectedWorkItemIdByPhase[detailModalPhase]; + if (!workItemId) return; const ok = await ensureCustom(); if (!ok) return; - const content = addDetailContent.trim() || - DETAIL_TYPES.find(d => d.value === addDetailType)?.label || addDetailType; - - const newDetail: WIWorkItemDetail = { - id: `temp-detail-${Date.now()}`, - work_item_id: selectedWorkItemId, - detail_type: addDetailType, - content, - is_required: addDetailRequired, - sort_order: (selectedWorkItem?.details?.length || 0) + 1, - }; + // content 자동 설정 (UI에서 직접 입력이 없는 유형들) + const submitData = { ...detailFormData }; + if (type === "lookup") submitData.content = submitData.content || "품목 등록 문서 (자동 연동)"; + if (type === "equip_inspection") submitData.content = submitData.content || "설비점검"; + if (type === "production_result") submitData.content = submitData.content || "작업수량 / 불량수량 / 양품수량"; + if (type === "material_input") submitData.content = submitData.content || "BOM 구성 자재 (자동 연동)"; setProcesses(prev => { const next = [...prev]; const workItems = [...next[selectedProcessIdx].workItems]; - const wiIdx = workItems.findIndex(wi => wi.id === selectedWorkItemId); - if (wiIdx >= 0) { + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); + if (wiIdx < 0) return prev; + + if (detailModalMode === "edit" && editingDetail) { + // 수정 + const details = (workItems[wiIdx].details || []).map(d => + d.id === editingDetail.id ? { ...d, ...submitData } : d + ); + workItems[wiIdx] = { ...workItems[wiIdx], details }; + } else { + // 추가 + const newDetail: WIWorkItemDetail = { + ...submitData, + id: `temp-detail-${Date.now()}`, + work_item_id: workItemId, + sort_order: (workItems[wiIdx].details?.length || 0) + 1, + }; workItems[wiIdx] = { ...workItems[wiIdx], details: [...(workItems[wiIdx].details || []), newDetail], @@ -214,23 +346,21 @@ export function WorkStandardEditModal({ return next; }); - setAddDetailContent(""); - setAddDetailType("checklist"); - setAddDetailRequired("N"); - setAddDetailOpen(false); + setDetailModalOpen(false); setDirty(true); - }, [addDetailContent, addDetailType, addDetailRequired, selectedWorkItemId, selectedWorkItem, ensureCustom, selectedProcessIdx]); + }, [detailFormData, detailModalPhase, detailModalMode, editingDetail, selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); // 상세 삭제 - const handleDeleteDetail = useCallback(async (detailId: string) => { - if (!selectedWorkItemId) return; + const handleDeleteDetail = useCallback(async (detailId: string, phaseKey: string) => { + const workItemId = selectedWorkItemIdByPhase[phaseKey]; + if (!workItemId) return; const ok = await ensureCustom(); if (!ok) return; setProcesses(prev => { const next = [...prev]; const workItems = [...next[selectedProcessIdx].workItems]; - const wiIdx = workItems.findIndex(wi => wi.id === selectedWorkItemId); + const wiIdx = workItems.findIndex(wi => wi.id === workItemId); if (wiIdx >= 0) { workItems[wiIdx] = { ...workItems[wiIdx], @@ -242,7 +372,7 @@ export function WorkStandardEditModal({ return next; }); setDirty(true); - }, [selectedWorkItemId, ensureCustom, selectedProcessIdx]); + }, [selectedWorkItemIdByPhase, ensureCustom, selectedProcessIdx]); // 저장 const handleSave = useCallback(async () => { @@ -285,8 +415,9 @@ export function WorkStandardEditModal({ } }, [workInstructionNo, loadData]); - const getDetailTypeLabel = (type: string) => - DETAIL_TYPES.find(d => d.value === type)?.label || type; + const updateDetailField = (field: string, value: unknown) => { + setDetailFormData(prev => ({ ...prev, [field]: value })); + }; return ( { if (!v) onClose(); }}> @@ -324,7 +455,7 @@ export function WorkStandardEditModal({ className={cn("text-xs shrink-0 h-8", selectedProcessIdx === idx && "shadow-sm")} onClick={() => { setSelectedProcessIdx(idx); - setSelectedWorkItemId(null); + setSelectedWorkItemIdByPhase({}); }} > {proc.seq_no}. @@ -336,120 +467,185 @@ export function WorkStandardEditModal({ ))} - {/* 작업 단계 탭 */} -
+ {/* Phase별 세로 섹션 */} +
{PHASES.map(phase => { - const count = currentProcess?.workItems.filter(wi => wi.work_phase === phase.key).length || 0; + const phaseItems = workItemsByPhase[phase.key] || []; + const selectedWiId = selectedWorkItemIdByPhase[phase.key] || null; + const selectedWi = phaseItems.find(wi => wi.id === selectedWiId) || null; + return ( - - ); - })} -
- - {/* 작업항목 + 상세 split */} -
- {/* 좌측: 작업항목 목록 */} -
-
- 작업항목 - -
-
- {currentWorkItems.length === 0 ? ( -
작업항목이 없습니다
- ) : currentWorkItems.map((wi) => ( -
setSelectedWorkItemId(wi.id!)} - > -
-
-
{wi.title}
-
- {wi.is_required === "Y" && 필수} - 상세 {wi.details?.length || wi.detail_count || 0}건 -
-
- +
+ {/* 섹션 헤더 */} +
+
+

{phase.label}

+ + {phaseItems.length}개 항목 +
-
- ))} -
-
- - {/* 우측: 상세 목록 */} -
- {!selectedWorkItem ? ( -
- -

좌측에서 작업항목을 선택하세요

-
- ) : ( - <> -
-
- {selectedWorkItem.title} - 상세 항목 -
-
-
- {(!selectedWorkItem.details || selectedWorkItem.details.length === 0) ? ( -
상세 항목이 없습니다
- ) : selectedWorkItem.details.map((detail, dIdx) => ( -
- -
-
- - {getDetailTypeLabel(detail.detail_type || "checklist")} - - {detail.is_required === "Y" && 필수} -
-

{detail.content || "-"}

- {detail.remark &&

{detail.remark}

} - {detail.detail_type === "inspection" && (detail.lower_limit || detail.upper_limit) && ( -
- 범위: {detail.lower_limit || "-"} ~ {detail.upper_limit || "-"} {detail.unit || ""} -
- )} + + {/* 좌우 분할 */} +
+ {/* 좌측: 240px 작업항목 카드 */} +
+ {phaseItems.length === 0 ? ( +
+ +

등록된 항목이 없습니다

- -
- ))} + ) : ( +
+ {phaseItems.map(item => ( +
setSelectedWorkItemIdByPhase(prev => ({ ...prev, [phase.key]: item.id! }))} + className={cn( + "group flex cursor-pointer items-start gap-2 rounded-lg border p-3 transition-all", + "hover:border-primary/30 hover:shadow-sm", + selectedWiId === item.id + ? "border-primary bg-primary/5 shadow-sm" + : "border-border bg-card" + )} + > + +
+
+ {item.title} +
+
+ + {item.details?.length || item.detail_count || 0}개 + + + {item.is_required === "Y" ? "필수" : "선택"} + +
+
+
+ + +
+
+ ))} +
+ )} +
+ + {/* 우측: 상세 테이블 */} +
+ {!selectedWi ? ( +
+

왼쪽에서 항목을 선택하세요

+
+ ) : ( + <> + {/* 상세 헤더 */} +
+
+ {selectedWi.title} + + {selectedWi.details?.length || 0}개 + +
+ +
+ + {/* 테이블 */} +
+ + + + + + + + + + + + {(selectedWi.details || []).map((detail, idx) => ( + + + + + + + + ))} + +
순서유형내용필수관리
{idx + 1} + + {getDetailTypeLabel(detail.detail_type || "checklist")} + + {getContentSummary(detail)} + + {detail.is_required === "Y" ? "필수" : "선택"} + + +
+ + +
+
+ + {(!selectedWi.details || selectedWi.details.length === 0) && ( +
+

+ 상세 항목이 없습니다. "상세 추가" 버튼을 클릭하여 추가하세요. +

+
+ )} +
+ + )} +
- - )} -
+
+ ); + })}
)} @@ -471,13 +667,15 @@ export function WorkStandardEditModal({
- {/* 작업항목 추가 다이얼로그 */} + {/* 작업항목 추가/수정 다이얼로그 */} e.stopPropagation()}> - 작업항목 추가 + + 작업항목 {editingWorkItem ? "수정" : "추가"} + - {PHASES.find(p => p.key === selectedPhase)?.label} 단계에 작업항목을 추가합니다. + {PHASES.find(p => p.key === addItemPhase)?.label} 단계에 작업항목을 {editingWorkItem ? "수정" : "추가"}합니다.
@@ -492,25 +690,39 @@ export function WorkStandardEditModal({
- +
- {/* 상세 추가 다이얼로그 */} - - e.stopPropagation()}> + {/* 상세 추가/수정 다이얼로그 (유형별 동적 필드) */} + { if (!v) setDetailModalOpen(false); }}> + e.stopPropagation()}> - 상세 항목 추가 + + 상세 항목 {detailModalMode === "add" ? "추가" : "수정"} + - "{selectedWorkItem?.title}"에 상세 항목을 추가합니다. + 상세 항목의 유형을 선택하고 내용을 입력하세요 -
+ +
+ {/* 유형 선택 */}
- - { + setDetailFormData({ + detail_type: v, + is_required: detailFormData.is_required || "Y", + }); + }} + > + + + {DETAIL_TYPES.map(dt => ( {dt.label} @@ -518,18 +730,274 @@ export function WorkStandardEditModal({
-
- - setAddDetailContent(e.target.value)} placeholder="상세 내용 입력" className="h-8 text-xs mt-1" /> -
-
- setAddDetailRequired(v ? "Y" : "N")} /> - -
+ + {/* 체크리스트 */} + {detailFormData.detail_type === "checklist" && ( +
+ + updateDetailField("content", e.target.value)} + placeholder="예: 전원 상태 확인" + className="mt-1 h-8 text-xs" + /> +
+ )} + + {/* 검사항목 */} + {detailFormData.detail_type === "inspection" && ( + <> +
+ + updateDetailField("content", e.target.value)} + placeholder="예: 외경 치수" + className="mt-1 h-8 text-xs" + /> +
+
+
+ + updateDetailField("inspection_method", e.target.value)} + placeholder="예: 마이크로미터" + className="mt-1 h-8 text-xs" + /> +
+
+ + +
+
+
+
+
+ updateDetailField("base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs" + /> +
+ ± +
+ updateDetailField("tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs" + /> +
+
+
+ + )} + + {/* 작업절차 */} + {detailFormData.detail_type === "procedure" && ( + <> +
+ + updateDetailField("content", e.target.value)} + placeholder="예: 자재 투입" + className="mt-1 h-8 text-xs" + /> +
+
+ + updateDetailField("duration_minutes", e.target.value ? Number(e.target.value) : undefined)} + placeholder="예: 5" + className="mt-1 h-8 text-xs" + /> +
+ + )} + + {/* 직접입력 */} + {detailFormData.detail_type === "input" && ( + <> +
+ + updateDetailField("content", e.target.value)} + placeholder="예: 작업자 의견" + className="mt-1 h-8 text-xs" + /> +
+
+ + +
+ + )} + + {/* 문서참조 */} + {detailFormData.detail_type === "lookup" && ( +
+ + 해당 품목에 등록된 문서를 자동으로 불러옵니다. + +
+ )} + + {/* 설비점검 */} + {detailFormData.detail_type === "equip_inspection" && ( +
+ + updateDetailField("content", e.target.value)} + placeholder="예: 설비 가동 전 안전 점검" + className="mt-1 h-8 text-xs" + /> +
+ )} + + {/* 설비조건 */} + {detailFormData.detail_type === "equip_condition" && ( + <> +
+ + updateDetailField("content", e.target.value)} + placeholder="조건명 (예: 온도, 압력, RPM)" + className="mt-1 h-8 text-xs" + /> +
+
+
+
+ +
+
+
+
+ updateDetailField("condition_base_value", e.target.value)} + placeholder="기준값" + className="h-8 text-xs" + /> +
+ ± +
+ updateDetailField("condition_tolerance", e.target.value)} + placeholder="오차범위" + className="h-8 text-xs" + /> +
+
+
+ + )} + + {/* 실적등록 */} + {detailFormData.detail_type === "production_result" && ( +
+

작업수량 / 불량수량 / 양품수량

+

실적 입력 항목이 자동으로 생성됩니다.

+
+ )} + + {/* 자재투입 */} + {detailFormData.detail_type === "material_input" && ( +
+

BOM 구성 자재 (자동 연동)

+

품목에 등록된 BOM 구성 자재가 자동으로 적용됩니다.

+
+ )} + + {/* 필수 여부 (모든 유형 공통) */} + {detailFormData.detail_type && ( +
+ + +
+ )} + + {/* 비고 (모든 유형 공통) */} + {detailFormData.detail_type && ( +
+ + updateDetailField("remark", e.target.value)} + placeholder="비고 입력" + className="mt-1 h-8 text-xs" + /> +
+ )}
+ - - + +
diff --git a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx index 369eac6a..d36f56ad 100644 --- a/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx @@ -9,6 +9,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { Label } from "@/components/ui/label"; import { Plus, Trash2, RotateCcw, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown, ClipboardCheck } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -39,6 +40,7 @@ interface EmployeeOption { user_id: string; user_name: string; dept_name: string interface SelectedItem { itemCode: string; itemName: string; spec: string; qty: number; remark: string; sourceType: SourceType; sourceTable: string; sourceId: string | number; + routing?: string; routingOptions?: RoutingVersionData[]; } export default function WorkInstructionPage() { @@ -206,14 +208,17 @@ export default function WorkInstructionPage() { setConfirmRouting(""); setConfirmRoutingOptions([]); previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)")); - // 첫 번째 품목의 라우팅 로드 - const firstItem = items.length > 0 ? items[0] : null; - if (firstItem) { - getRoutingVersions("__new__", firstItem.itemCode).then(r => { + // 품목별 라우팅 옵션 로드 + const finalItems = regMergeSameItem ? Array.from(new Map(items.map(i => [i.itemCode, i])).values()) : items; + const uniqueItemCodes = [...new Set(finalItems.map(i => i.itemCode).filter(Boolean))]; + for (const ic of uniqueItemCodes) { + getRoutingVersions("__new__", ic).then(r => { if (r.success && r.data) { - setConfirmRoutingOptions(r.data); - const defaultRouting = r.data.find(rv => rv.is_default); - if (defaultRouting) setConfirmRouting(defaultRouting.id); + setConfirmItems(prev => prev.map(it => { + if (it.itemCode !== ic) return it; + const defaultRv = r.data.find(rv => rv.is_default); + return { ...it, routingOptions: r.data, routing: defaultRv?.id || "" }; + })); } }).catch(() => {}); } @@ -242,7 +247,7 @@ export default function WorkInstructionPage() { status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate, equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker, routing: confirmRouting || null, - items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })), + items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); } @@ -258,21 +263,35 @@ export default function WorkInstructionPage() { setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || ""); setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || ""); setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || ""); - setEditItems(relatedDetails.map((d: any) => ({ + const items: SelectedItem[] = relatedDetails.map((d: any) => ({ itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "", qty: Number(d.detail_qty || 0), remark: d.detail_remark || "", sourceType: (d.source_table === "sales_order_detail" ? "order" : d.source_table === "production_plan_mng" ? "production" : "item") as SourceType, sourceTable: d.source_table || "item_info", sourceId: d.source_id || "", - }))); + routing: d.detail_routing_version_id || order.routing_version_id || "", + routingOptions: [], + })); + setEditItems(items); setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker(""); setEditRouting(order.routing_version_id || ""); setEditRoutingOptions([]); - // 라우팅 옵션 로드 - const itemCode = order.item_number || order.part_code || ""; - if (itemCode) { - getRoutingVersions(wiNo, itemCode).then(r => { - if (r.success && r.data) setEditRoutingOptions(r.data); + // 품목별 라우팅 옵션 로드 + const uniqueItemCodes = [...new Set(items.map(i => i.itemCode).filter(Boolean))]; + for (const ic of uniqueItemCodes) { + getRoutingVersions(wiNo, ic).then(r => { + if (r.success && r.data) { + setEditItems(prev => prev.map(it => { + if (it.itemCode !== ic) return it; + const opts = r.data; + const hasRouting = it.routing && opts.some(rv => rv.id === it.routing); + return { + ...it, + routingOptions: opts, + routing: hasRouting ? it.routing : (opts.find(rv => rv.is_default)?.id || it.routing || ""), + }; + })); + } }).catch(() => {}); } @@ -296,7 +315,7 @@ export default function WorkInstructionPage() { id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate, equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark, routing: editRouting || null, - items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })), + items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode, routing: i.routing || null })), }; const r = await saveWorkInstruction(payload); if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); } @@ -600,13 +619,20 @@ export default function WorkInstructionPage() {
{/* ── 2단계: 확인 모달 ── */} - - - - 작업지시 적용 확인 - 기본 정보를 입력하고 "최종 적용" 버튼을 눌러주세요. - -
+ + + + + + } + > +

작업지시 기본 정보

@@ -625,27 +651,24 @@ export default function WorkInstructionPage() {
-
- -
+

품목 목록

-
+
- 순번품목코드품목명규격수량비고 + + 순번 + 품목코드 + 품목명 + 규격 + 수량 + 라우팅 + 비고 + + {confirmItems.map((item, idx) => ( @@ -655,6 +678,25 @@ export default function WorkInstructionPage() { {item.itemName || item.itemCode} {item.spec || "-"} setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> + + + setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> @@ -664,22 +706,22 @@ export default function WorkInstructionPage() { - - - - - - - + {/* ── 수정 모달 ── */} - - - - 작업지시 관리 - {editOrder?.work_instruction_no} - 품목을 추가/삭제하고 정보를 수정하세요. - -
+ { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }} + title={`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`} + description="품목을 추가/삭제하고 정보를 수정하세요." + footer={ + <> + + + + } + > +

기본 정보

@@ -691,64 +733,81 @@ export default function WorkInstructionPage() {
-
- -
-
- -
setEditRemark(e.target.value)} className="h-9" placeholder="비고" />
- {/* 품목 테이블 */} + {/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
작업지시 항목 {editItems.length}건
-
+
- 순번품목코드품목명규격수량비고 + + 순번 + 품목코드 + 품목명 + 규격 + 수량 + 라우팅 + 공정작업기준 + 비고 + + {editItems.length === 0 ? ( - 품목이 없습니다 + 품목이 없습니다 ) : editItems.map((item, idx) => ( {idx + 1} {item.itemCode} - {item.itemName || "-"} - {item.spec || "-"} + {item.itemName || "-"} + {item.spec || "-"} setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /> + + + + + + setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /> @@ -764,12 +823,7 @@ export default function WorkInstructionPage() { )} - - - - - - + {/* 공정작업기준 수정 모달 */} ([]); const [priceLoading, setPriceLoading] = useState(false); + const [priceCheckedIds, setPriceCheckedIds] = useState([]); // 우측: 납품처 const [deliveryItems, setDeliveryItems] = useState([]); + const [deliveryCheckedIds, setDeliveryCheckedIds] = useState([]); // 품목 편집 데이터 (더블클릭 시 — 상세 입력 모달 재활용) const [editItemData, setEditItemData] = useState(null); @@ -254,7 +256,8 @@ export default function CustomerManagementPage() { const selectedCustomer = customers.find((c) => c.id === selectedCustomerId); useEffect(() => { - if (!selectedCustomer?.customer_code) { setPriceItems([]); return; } + if (!selectedCustomer?.customer_code) { setPriceItems([]); setPriceCheckedIds([]); return; } + setPriceCheckedIds([]); const fetchItems = async () => { setPriceLoading(true); try { @@ -345,7 +348,8 @@ export default function CustomerManagementPage() { // 납품처 조회 useEffect(() => { - if (!selectedCustomer?.customer_code) { setDeliveryItems([]); return; } + if (!selectedCustomer?.customer_code) { setDeliveryItems([]); setDeliveryCheckedIds([]); return; } + setDeliveryCheckedIds([]); const fetchDelivery = async () => { setDeliveryLoading(true); try { @@ -457,6 +461,8 @@ export default function CustomerManagementPage() { try { const filters: any[] = []; if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); + // 영업관리/판매품 division 필터 (다중값 contains 매칭) + filters.push({ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" }); const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 50, dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, @@ -784,6 +790,69 @@ export default function CustomerManagementPage() { } }; + // 우측: 품목 매핑 삭제 (관련 단가도 함께 삭제) + const handlePriceItemDelete = async () => { + if (priceCheckedIds.length === 0) return; + const ok = await confirm(`선택한 ${priceCheckedIds.length}개 품목 매핑을 삭제하시겠습니까?`, { + description: "관련된 단가 정보도 함께 삭제됩니다.", + variant: "destructive", confirmText: "삭제", + }); + if (!ok) return; + try { + // 매핑 삭제 — 관련 단가는 서버에서 cascade 또는 별도 삭제 + // 먼저 관련 단가 삭제 시도 + for (const mappingId of priceCheckedIds) { + try { + const priceRes = await apiClient.post(`/table-management/tables/${PRICE_TABLE}/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] }, + autoFilter: true, + }); + const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + if (prices.length > 0) { + await apiClient.delete(`/table-management/tables/${PRICE_TABLE}/delete`, { + data: prices.map((p: any) => ({ id: p.id })), + }); + } + } catch { /* skip */ } + } + // 매핑 삭제 + await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, { + data: priceCheckedIds.map((id) => ({ id })), + }); + toast.success(`${priceCheckedIds.length}개 품목 매핑이 삭제되었습니다.`); + setPriceCheckedIds([]); + // 우측 새로고침 + const cid = selectedCustomerId; + setSelectedCustomerId(null); + setTimeout(() => setSelectedCustomerId(cid), 50); + } catch { + toast.error("삭제에 실패했습니다."); + } + }; + + // 우측: 납품처 삭제 + const handleDeliveryDelete = async () => { + if (deliveryCheckedIds.length === 0) return; + const ok = await confirm(`선택한 ${deliveryCheckedIds.length}개 납품처를 삭제하시겠습니까?`, { + variant: "destructive", confirmText: "삭제", + }); + if (!ok) return; + try { + await apiClient.delete(`/table-management/tables/${DELIVERY_TABLE}/delete`, { + data: deliveryCheckedIds.map((id) => ({ id })), + }); + toast.success(`${deliveryCheckedIds.length}개 납품처가 삭제되었습니다.`); + setDeliveryCheckedIds([]); + // 우측 새로고침 + const cid = selectedCustomerId; + setSelectedCustomerId(null); + setTimeout(() => setSelectedCustomerId(cid), 50); + } catch { + toast.error("삭제에 실패했습니다."); + } + }; + // 엑셀 다운로드 const handleExcelDownload = async () => { if (customers.length === 0) return; @@ -965,16 +1034,28 @@ export default function CustomerManagementPage() {
{rightTab === "items" && ( - + <> + + + )} {rightTab === "delivery" && ( - + <> + + + )}
@@ -991,6 +1072,9 @@ export default function CustomerManagementPage() { data={priceItems} loading={priceLoading} showRowNumber={false} + showCheckbox + checkedIds={priceCheckedIds} + onCheckedChange={setPriceCheckedIds} tableName={MAPPING_TABLE} emptyMessage="등록된 품목이 없습니다" onRowDoubleClick={(row) => openEditItem(row)} @@ -1010,6 +1094,9 @@ export default function CustomerManagementPage() { data={deliveryItems} loading={deliveryLoading} showRowNumber={false} + showCheckbox + checkedIds={deliveryCheckedIds} + onCheckedChange={setDeliveryCheckedIds} tableName={DELIVERY_TABLE} emptyMessage="등록된 납품처가 없습니다" /> diff --git a/frontend/app/(main)/COMPANY_7/sales/order/page.tsx b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx index 7f23909c..d1bd0c17 100644 --- a/frontend/app/(main)/COMPANY_7/sales/order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx @@ -209,6 +209,12 @@ export default function SalesOrderPage() { } catch { /* skip */ } } setCategoryOptions(optMap); + // division 기본값: 영업관리/제품/판매품 라벨 순서로 탐색 + const divs = optMap["item_division"] || []; + const salesDiv = divs.find((o: any) => o.label === "영업관리") + || divs.find((o: any) => o.label === "제품") + || divs.find((o: any) => o.label === "판매품"); + if (salesDiv) setItemSearchDivision(salesDiv.code); }; loadCategories(); }, []); @@ -326,7 +332,7 @@ export default function SalesOrderPage() { const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || ""; const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || ""; const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || ""; - setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, manager_id: user?.userId || "" }); + setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, manager_id: user?.userId || "", order_date: new Date().toISOString().split("T")[0] }); setDetailRows([]); setDeliveryOptions([]); setIsEditMode(false); @@ -474,16 +480,9 @@ export default function SalesOrderPage() { if (itemSearchKeyword) { filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); } + // division 필터는 다중값(쉼표 구분)이므로 contains로 부분 매칭 if (itemSearchDivision !== "all") { - filters.push({ columnName: "division", operator: "equals", value: itemSearchDivision }); - } else { - // 기본: 영업관련 division만 (판매품, 제품, 영업관리 등) - const salesDivCodes = (categoryOptions["item_division"] || []) - .filter((o) => ["판매품", "제품", "영업관리"].some((label) => o.label.includes(label))) - .map((o) => o.code); - if (salesDivCodes.length > 0) { - filters.push({ columnName: "division", operator: "in", value: salesDivCodes }); - } + filters.push({ columnName: "division", operator: "contains", value: itemSearchDivision }); } const res = await apiClient.post(`/table-management/tables/item_info/data`, { page: p, size: s, diff --git a/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx index 5da2b3b5..7847fa55 100644 --- a/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx @@ -79,6 +79,7 @@ export default function SalesItemPage() { // 우측: 거래처 const [customerItems, setCustomerItems] = useState([]); const [customerLoading, setCustomerLoading] = useState(false); + const [customerCheckedIds, setCustomerCheckedIds] = useState([]); // 카테고리 const [categoryOptions, setCategoryOptions] = useState>({}); @@ -166,8 +167,8 @@ export default function SalesItemPage() { try { const filters: { columnName: string; operator: string; value: any }[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); - // 판매품목 division 필터 (다중값 컬럼이므로 contains로 매칭) - filters.push({ columnName: "division", operator: "contains", value: "CAT_DIV_SALES" }); + // 판매품목/영업관리 division 필터 (다중값 컬럼이므로 contains로 매칭) + filters.push({ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" }); const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { page: 1, size: 500, @@ -200,12 +201,13 @@ export default function SalesItemPage() { // 우측: 거래처 목록 조회 useEffect(() => { - if (!selectedItem?.item_number) { setCustomerItems([]); return; } + if (!selectedItem?.item_number) { setCustomerItems([]); setCustomerCheckedIds([]); return; } + setCustomerCheckedIds([]); const itemKey = selectedItem.item_number; const fetchCustomerItems = async () => { setCustomerLoading(true); try { - // customer_item_mapping에서 해당 품목의 매핑 조회 + // 1. customer_item_mapping에서 해당 품목의 매핑 조회 const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, { page: 1, size: 500, dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] }, @@ -213,7 +215,7 @@ export default function SalesItemPage() { }); const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || []; - // customer_id → customer_mng 조인 (거래처명) + // 2. customer_id → customer_mng 조인 (거래처명) const custIds = [...new Set(mappings.map((m: any) => m.customer_id).filter(Boolean))]; let custMap: Record = {}; if (custIds.length > 0) { @@ -229,11 +231,54 @@ export default function SalesItemPage() { } catch { /* skip */ } } - setCustomerItems(mappings.map((m: any) => ({ - ...m, - customer_code: m.customer_id, - customer_name: custMap[m.customer_id]?.customer_name || "", - }))); + // 3. customer_item_prices 조회 (단가 정보) + let allPrices: any[] = []; + if (mappings.length > 0) { + try { + const priceRes = await apiClient.post(`/table-management/tables/customer_item_prices/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [ + { columnName: "item_id", operator: "equals", value: itemKey }, + ]}, + autoFilter: true, + }); + allPrices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + } catch { /* skip */ } + } + + // 4. 거래처별 중복 제거 + 오늘 날짜 기준 단가 매칭 + const priceResolve = (col: string, code: string) => { + if (!code) return ""; + return priceCategoryOptions[col]?.find((o: any) => o.code === code)?.label || code; + }; + const today = new Date().toISOString().split("T")[0]; + const seenCustIds = new Set(); + + // customer_id로 정렬하여 같은 거래처끼리 묶기 + const sortedMappings = [...mappings].sort((a: any, b: any) => (a.customer_id || "").localeCompare(b.customer_id || "")); + + setCustomerItems(sortedMappings.map((m: any) => { + const custKey = m.customer_id || ""; + const isFirstOfGroup = !seenCustIds.has(custKey); + if (custKey) seenCustIds.add(custKey); + + // 오늘 날짜에 해당하는 단가 + const custPriceList = allPrices.filter((p: any) => p.customer_id === custKey); + const todayPrice = custPriceList.find((p: any) => + (!p.start_date || p.start_date <= today) && (!p.end_date || p.end_date >= today) + ) || custPriceList[0] || {}; + + return { + ...m, + customer_code: isFirstOfGroup ? custKey : "", + customer_name: isFirstOfGroup ? (custMap[custKey]?.customer_name || "") : "", + customer_item_code: m.customer_item_code || "", + customer_item_name: m.customer_item_name || "", + base_price: todayPrice.base_price || "", + calculated_price: todayPrice.calculated_price || todayPrice.unit_price || "", + currency_code: priceResolve("currency_code", todayPrice.currency_code || ""), + }; + })); } catch (err) { console.error("거래처 조회 실패:", err); } finally { @@ -241,7 +286,7 @@ export default function SalesItemPage() { } }; fetchCustomerItems(); - }, [selectedItem?.item_number]); + }, [selectedItem?.item_number, priceCategoryOptions]); // 거래처 검색 const searchCustomers = async () => { @@ -523,6 +568,46 @@ export default function SalesItemPage() { } }; + // 우측: 거래처 매핑 삭제 + const handleCustomerMappingDelete = async () => { + if (customerCheckedIds.length === 0) return; + const ok = await confirm(`선택한 ${customerCheckedIds.length}개 거래처 매핑을 삭제하시겠습니까?`, { + description: "관련된 단가 정보도 함께 삭제됩니다.", + variant: "destructive", confirmText: "삭제", + }); + if (!ok) return; + try { + // 관련 단가 삭제 + for (const mappingId of customerCheckedIds) { + try { + const priceRes = await apiClient.post(`/table-management/tables/customer_item_prices/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "mapping_id", operator: "equals", value: mappingId }] }, + autoFilter: true, + }); + const prices = priceRes.data?.data?.data || priceRes.data?.data?.rows || []; + if (prices.length > 0) { + await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, { + data: prices.map((p: any) => ({ id: p.id })), + }); + } + } catch { /* skip */ } + } + // 매핑 삭제 + await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, { + data: customerCheckedIds.map((id) => ({ id })), + }); + toast.success(`${customerCheckedIds.length}개 거래처 매핑이 삭제되었습니다.`); + setCustomerCheckedIds([]); + // 우측 새로고침 + const sid = selectedItemId; + setSelectedItemId(null); + setTimeout(() => setSelectedItemId(sid), 50); + } catch { + toast.error("삭제에 실패했습니다."); + } + }; + // 엑셀 다운로드 const handleExcelDownload = async () => { if (items.length === 0) return; @@ -598,10 +683,16 @@ export default function SalesItemPage() { 거래처 정보 {selectedItem && {selectedItem.item_name}} - +
+ + +
{!selectedItemId ? (
@@ -614,6 +705,9 @@ export default function SalesItemPage() { data={customerItems} loading={customerLoading} showRowNumber={false} + showCheckbox + checkedIds={customerCheckedIds} + onCheckedChange={setCustomerCheckedIds} emptyMessage="등록된 거래처가 없습니다" onRowDoubleClick={(row) => openEditCust(row)} /> diff --git a/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx b/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx index 5ff26159..25e52ead 100644 --- a/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx +++ b/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx @@ -76,6 +76,7 @@ export default function ShippingOrderPage() { const [orders, setOrders] = useState([]); const [loading, setLoading] = useState(false); const [checkedIds, setCheckedIds] = useState([]); + const [selectedOrderId, setSelectedOrderId] = useState(null); // 검색 const [searchKeyword, setSearchKeyword] = useState(""); @@ -526,7 +527,7 @@ export default function ShippingOrderPage() { const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : []; if (items.length === 0) { return ( - openModal(order)}> + setSelectedOrderId(order.id)} onDoubleClick={() => openModal(order)}> e.stopPropagation()}> { if (c) setCheckedIds(p => [...p, order.id]); @@ -551,7 +552,7 @@ export default function ShippingOrderPage() { ); } return items.map((item: any, itemIdx: number) => ( - openModal(order)}> + setSelectedOrderId(order.id)} onDoubleClick={() => openModal(order)}> e.stopPropagation()}> {itemIdx === 0 && { if (c) setCheckedIds(p => [...p, order.id]); diff --git a/frontend/app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx index 1375eeb3..ec5abe07 100644 --- a/frontend/app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx +++ b/frontend/app/(main)/admin/screenMng/reportList/designer/[reportId]/page.tsx @@ -1,29 +1,87 @@ "use client"; -import { useEffect, useState } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState, useCallback } from "react"; +import { useParams } from "next/navigation"; import { DndProvider } from "react-dnd"; +import { useTabStore } from "@/stores/tabStore"; +import { useTabId } from "@/contexts/TabIdContext"; import { HTML5Backend } from "react-dnd-html5-backend"; import { ReportDesignerToolbar } from "@/components/report/designer/ReportDesignerToolbar"; import { PageListPanel } from "@/components/report/designer/PageListPanel"; import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel"; import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas"; import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel"; -import { ReportDesignerProvider } from "@/contexts/ReportDesignerContext"; +import { ReportDesignerProvider, useReportDesigner } from "@/contexts/ReportDesignerContext"; +import { ComponentSettingsModal } from "@/components/report/designer/modals/ComponentSettingsModal"; import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; import { Loader2 } from "lucide-react"; -export default function ReportDesignerPage() { - const params = useParams(); - const router = useRouter(); - const reportId = params.reportId as string; +const BREAKPOINT_COLLAPSE_LEFT = 1200; +const BREAKPOINT_COLLAPSE_ALL = 900; + +function DesignerLayout() { + const { + setIsPageListCollapsed, + setIsLeftPanelCollapsed, + setIsRightPanelCollapsed, + } = useReportDesigner(); + + const handleResize = useCallback(() => { + const w = window.innerWidth; + if (w < BREAKPOINT_COLLAPSE_ALL) { + setIsPageListCollapsed(true); + setIsLeftPanelCollapsed(true); + setIsRightPanelCollapsed(true); + } else if (w < BREAKPOINT_COLLAPSE_LEFT) { + setIsPageListCollapsed(true); + setIsLeftPanelCollapsed(false); + setIsRightPanelCollapsed(false); + } + }, [setIsPageListCollapsed, setIsLeftPanelCollapsed, setIsRightPanelCollapsed]); + + useEffect(() => { + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [handleResize]); + + return ( +
+ + +
+ + + + +
+ + +
+ ); +} + +interface ReportDesignerPageProps { + adminParams?: { reportId?: string }; +} + +export default function ReportDesignerPage({ adminParams }: ReportDesignerPageProps) { + const routeParams = useParams(); + const reportId = adminParams?.reportId || (routeParams.reportId as string); const [isLoading, setIsLoading] = useState(true); const { toast } = useToast(); + const closeTab = useTabStore((s) => s.closeTab); + const currentTabId = useTabId(); + + const closeDesignerTab = useCallback(() => { + if (currentTabId) { + closeTab(currentTabId); + } + }, [currentTabId, closeTab]); useEffect(() => { const loadReport = async () => { - // 'new'는 새 리포트 생성 모드 if (reportId === "new") { setIsLoading(false); return; @@ -37,7 +95,7 @@ export default function ReportDesignerPage() { description: "리포트를 찾을 수 없습니다.", variant: "destructive", }); - router.push("/admin/screenMng/reportList"); + closeDesignerTab(); } } catch (error: any) { toast({ @@ -45,7 +103,7 @@ export default function ReportDesignerPage() { description: error.message || "리포트를 불러오는데 실패했습니다.", variant: "destructive", }); - router.push("/admin/screenMng/reportList"); + closeDesignerTab(); } finally { setIsLoading(false); } @@ -54,7 +112,7 @@ export default function ReportDesignerPage() { if (reportId) { loadReport(); } - }, [reportId, router, toast]); + }, [reportId, closeDesignerTab, toast]); if (isLoading) { return ( @@ -65,28 +123,12 @@ export default function ReportDesignerPage() { } return ( - - -
- {/* 상단 툴바 */} - - - {/* 메인 영역 */} -
- {/* 페이지 목록 패널 */} - - - {/* 좌측 패널 (템플릿, 컴포넌트) */} - - - {/* 중앙 캔버스 */} - - - {/* 우측 패널 (속성) */} - -
-
-
-
+
+ + + + + +
); } diff --git a/frontend/app/(main)/admin/screenMng/reportList/page.tsx b/frontend/app/(main)/admin/screenMng/reportList/page.tsx index e3f1e890..31b5cf4c 100644 --- a/frontend/app/(main)/admin/screenMng/reportList/page.tsx +++ b/frontend/app/(main)/admin/screenMng/reportList/page.tsx @@ -1,104 +1,528 @@ "use client"; -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { useState, useMemo, useRef, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Calendar } from "@/components/ui/calendar"; import { ReportListTable } from "@/components/report/ReportListTable"; -import { Plus, Search, RotateCcw } from "lucide-react"; +import { ReportCreateModal } from "@/components/report/ReportCreateModal"; +import { ReportCopyModal } from "@/components/report/ReportCopyModal"; +import { ReportListPreviewModal } from "@/components/report/ReportListPreviewModal"; +import { + Plus, + Search, + LayoutGrid, + List, + FileText, + Users, + SlidersHorizontal, + Check, + Tag, + CalendarDays, + User, + X, +} from "lucide-react"; import { useReportList } from "@/hooks/useReportList"; +import { ReportMaster } from "@/types/report"; +import { PieChart, Pie, Cell, Tooltip, BarChart, Bar, XAxis, LabelList } from "recharts"; +import { REPORT_TYPE_COLORS, getTypeColorIndex, getTypeLabel, getTypeIcon } from "@/lib/reportTypeColors"; +import { format, subDays, subMonths, startOfDay } from "date-fns"; +import { ko } from "date-fns/locale"; + +const SEARCH_FIELD_OPTIONS = [ + { value: "report_type" as const, label: "카테고리", icon: Tag }, + { value: "report_name" as const, label: "리포트명", icon: FileText }, + { value: "updated_at" as const, label: "기간 검색", icon: CalendarDays }, + { value: "created_by" as const, label: "작성자", icon: User }, +]; export default function ReportManagementPage() { - const router = useRouter(); const [searchText, setSearchText] = useState(""); + const [viewMode, setViewMode] = useState<"grid" | "list">("list"); + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [copyTarget, setCopyTarget] = useState(null); + const [viewTarget, setViewTarget] = useState(null); + const [filterOpen, setFilterOpen] = useState(false); + const [datePopoverOpen, setDatePopoverOpen] = useState(false); + const [tempStartDate, setTempStartDate] = useState(undefined); + const [tempEndDate, setTempEndDate] = useState(undefined); + const filterRef = useRef(null); - const { reports, total, page, limit, isLoading, refetch, setPage, handleSearch } = useReportList(); + const { + reports, + total, + typeSummary, + recentActivity, + recentTotal, + page, + limit, + isLoading, + searchField, + startDate, + endDate, + refetch, + setPage, + setLimit, + handleSearch, + handleSearchFieldChange, + handleDateRangeChange, + } = useReportList(); - const handleSearchClick = () => { - handleSearch(searchText); - }; + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (filterRef.current && !filterRef.current.contains(e.target as Node)) { + setFilterOpen(false); + setDatePopoverOpen(false); + } + }; + if (filterOpen) document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [filterOpen]); - const handleReset = () => { - setSearchText(""); - handleSearch(""); + const isDateFilterActive = searchField === "updated_at" && startDate && endDate; + + const handleDatePreset = useCallback((days: number) => { + const end = new Date(); + const start = days === 0 ? startOfDay(end) : subDays(end, days); + setTempStartDate(start); + setTempEndDate(end); + }, []); + + const handleMonthPreset = useCallback((months: number) => { + const end = new Date(); + const start = subMonths(end, months); + setTempStartDate(start); + setTempEndDate(end); + }, []); + + const handleApplyDateFilter = useCallback(() => { + if (!tempStartDate || !tempEndDate) return; + handleSearchFieldChange("updated_at"); + handleDateRangeChange(format(tempStartDate, "yyyy-MM-dd"), format(tempEndDate, "yyyy-MM-dd")); + setDatePopoverOpen(false); + setFilterOpen(false); + }, [tempStartDate, tempEndDate, handleSearchFieldChange, handleDateRangeChange]); + + const handleClearDateFilter = useCallback(() => { + setTempStartDate(undefined); + setTempEndDate(undefined); + handleSearchFieldChange("report_name"); + handleDateRangeChange("", ""); + }, [handleSearchFieldChange, handleDateRangeChange]); + + const typeData = useMemo(() => typeSummary.map(({ type, count }) => ({ type, value: count })), [typeSummary]); + + const authorStats = useMemo(() => { + const map = new Map(); + reports.forEach((r) => { + const author = r.created_by || "미지정"; + map.set(author, (map.get(author) || 0) + 1); + }); + return Array.from(map.entries()) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 3) + .sort((a, b) => a.name.localeCompare(b.name, "ko")); + }, [reports]); + + const authorCount = useMemo(() => new Set(reports.map((r) => r.created_by).filter(Boolean)).size, [reports]); + + const handleSearchClick = () => handleSearch(searchText); + + const handleViewModeChange = (mode: "grid" | "list") => { + setViewMode(mode); + setLimit(mode === "grid" ? 9 : 8); + setPage(1); }; const handleCreateNew = () => { - // 새 리포트는 'new'라는 특수 ID로 디자이너 진입 - router.push("/admin/screenMng/reportList/designer/new"); + setIsCreateOpen(true); }; - return ( -
-
- {/* 페이지 제목 */} -
-
-

리포트 관리

-

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

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

리포트 관리

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

+ {total.toLocaleString()} + +

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

+ {authorCount.toLocaleString()} + +

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

데이터 없음

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

{title}

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

▣ {title}

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

검 사 보 고 서

+

INSPECTION REPORT

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

▣ 검사/시험 측정값

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

리포트 디자인 샘플

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

+ WACE PLM — 문서 양식 샘플 +

+

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

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

{sample.docNo}

+
+
+

+ {sample.title} +

+

{sample.titleEng}

+

+ {sample.description} +

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

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

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

발 주 서

+

PURCHASE ORDER

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

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

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

견 적 서

+

QUOTATION

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

위와 같이 견적합니다.

+

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

+

감사합니다.

+
+ + {/* ── 결재란 ── */} + + + {/* ── 푸터 ── */} +
+
+
본 견적서의 유효기간은 견적일로부터 7일입니다.
+
+
+
결제계좌: (예금주: )
+
문의: TEL 000-0000-0000 / FAX 000-0000-0000
+
+
+
+ + ); +} diff --git a/frontend/components/common/DataGrid.tsx b/frontend/components/common/DataGrid.tsx index f122a318..0d717acf 100644 --- a/frontend/components/common/DataGrid.tsx +++ b/frontend/components/common/DataGrid.tsx @@ -22,7 +22,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Checkbox } from "@/components/ui/checkbox"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Input } from "@/components/ui/input"; -import { Filter, Check, Search, ImageIcon, X } from "lucide-react"; +import { Filter, Check, Search, ImageIcon, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { apiClient } from "@/lib/api/client"; @@ -66,6 +66,10 @@ export interface DataGridProps { loading?: boolean; onColumnOrderChange?: (columns: DataGridColumn[]) => void; gridId?: string; + /** 페이지네이션 표시 여부 (기본: true) */ + showPagination?: boolean; + /** 초기 페이지 크기 (기본: 50) */ + defaultPageSize?: number; } const fmtNum = (val: any) => { @@ -217,6 +221,8 @@ export function DataGrid({ loading = false, onColumnOrderChange, gridId, + showPagination = true, + defaultPageSize = 50, }: DataGridProps) { const [columns, setColumns] = useState(initialColumns); useEffect(() => { setColumns(initialColumns); }, [initialColumns]); @@ -228,6 +234,11 @@ export function DataGrid({ // 헤더 필터 (컬럼별 선택된 값 Set) const [headerFilters, setHeaderFilters] = useState>>({}); + // 페이지네이션 + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(defaultPageSize); + const [pageSizeInput, setPageSizeInput] = useState(String(defaultPageSize)); + // 인라인 편집 const [editingCell, setEditingCell] = useState<{ rowIdx: number; colKey: string } | null>(null); // 이미지 확대 모달 @@ -340,6 +351,53 @@ export function DataGrid({ return result; }, [data, headerFilters, sortKey, sortDir]); + // 필터/데이터 변경 시 1페이지로 리셋 + useEffect(() => { + setCurrentPage(1); + }, [data, headerFilters]); + + // 페이지네이션 계산 + const totalItems = processedData.length; + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + const safePage = Math.min(currentPage, totalPages); + + useEffect(() => { + if (currentPage > totalPages) setCurrentPage(totalPages); + }, [currentPage, totalPages]); + + const pageOffset = (safePage - 1) * pageSize; + const paginatedData = showPagination + ? processedData.slice(pageOffset, pageOffset + pageSize) + : processedData; + + // 페이지 크기 입력 적용 + const applyPageSize = () => { + const n = parseInt(pageSizeInput, 10); + if (!isNaN(n) && n >= 1) { + setPageSize(n); + setCurrentPage(1); + setPageSizeInput(String(n)); + } else { + setPageSizeInput(String(pageSize)); + } + }; + + // 페이지 번호 배열 생성 + const getPageNumbers = () => { + const delta = 2; + let start = Math.max(1, safePage - delta); + let end = Math.min(totalPages, safePage + delta); + if (end - start < delta * 2) { + if (start === 1) end = Math.min(totalPages, start + delta * 2); + else if (end === totalPages) start = Math.max(1, end - delta * 2); + } + const pages: (number | "...")[] = []; + if (start > 1) { pages.push(1); if (start > 2) pages.push("..."); } + for (let i = start; i <= end; i++) pages.push(i); + if (end < totalPages) { if (end < totalPages - 1) pages.push("..."); pages.push(totalPages); } + return pages; + }; + // 인라인 편집 const startEdit = (rowIdx: number, colKey: string, currentVal: any) => { const col = columns.find((c) => c.key === colKey); @@ -351,7 +409,7 @@ export function DataGrid({ const saveEdit = useCallback(async () => { if (!editingCell) return; const { rowIdx, colKey } = editingCell; - const row = processedData[rowIdx]; + const row = paginatedData[rowIdx]; if (!row) { setEditingCell(null); return; } const originalVal = String(row[colKey] ?? ""); @@ -374,7 +432,7 @@ export function DataGrid({ onCellEdit?.(row.id, colKey, editValue, row); setEditingCell(null); - }, [editingCell, editValue, processedData, tableName, onCellEdit]); + }, [editingCell, editValue, paginatedData, tableName, onCellEdit]); const cancelEdit = () => setEditingCell(null); @@ -441,7 +499,8 @@ export function DataGrid({ }; return ( -
+
+
@@ -481,13 +540,13 @@ export function DataGrid({ 로딩 중... - ) : processedData.length === 0 ? ( + ) : paginatedData.length === 0 ? ( {emptyMessage} - ) : processedData.map((row, rowIdx) => ( + ) : paginatedData.map((row, rowIdx) => ( )} - {showRowNumber && !showCheckbox && {rowIdx + 1}} + {showRowNumber && !showCheckbox && {pageOffset + rowIdx + 1}} {columns.map((col) => (
+
+ + {/* 페이지네이션 footer */} + {showPagination && ( +
+ {/* 좌측: 데이터 수량 + 페이지 크기 입력 */} +
+
+ 전체 + {totalItems.toLocaleString()} + +
+
+ setPageSizeInput(e.target.value)} + onBlur={applyPageSize} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }} + className="h-7 w-16 text-center text-xs" + /> + 건씩 보기 +
+
+ + {/* 중앙: 페이지 이동 버튼 */} +
+ + + {getPageNumbers().map((page, idx) => + page === "..." ? ( + ... + ) : ( + + ) + )} + + +
+ + {/* 우측: 좌측과 균형용 빈 공간 */} +
+
+ 전체 + {totalItems.toLocaleString()} + +
+
+
+ 건씩 보기 +
+
+
+ )} {/* 이미지 확대 모달 */} {previewImage && ( diff --git a/frontend/components/common/DynamicSearchFilter.tsx b/frontend/components/common/DynamicSearchFilter.tsx index e61c7c5e..1a0f8acd 100644 --- a/frontend/components/common/DynamicSearchFilter.tsx +++ b/frontend/components/common/DynamicSearchFilter.tsx @@ -175,6 +175,13 @@ export function DynamicSearchFilter({ width: f.width, })); setActiveFilters(active); + // allColumns도 동기화하여 설정 모달에서 일관된 상태 표시 + setAllColumns((prev) => + prev.map((col) => { + const ext = externalFilterConfig.find((f) => f.columnName === col.columnName); + return ext ? { ...col, enabled: ext.enabled, filterType: ext.filterType, width: ext.width } : col; + }) + ); }, [externalFilterConfig]); // select 타입 필터의 옵션 로드 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 9607a83d..1a134fa2 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -361,7 +361,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 && ( +
등록된 템플릿이 없습니다
+ )} + + +

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

+
+ +
+ +