Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into ycshin-node
This commit is contained in:
@@ -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`
|
||||||
@@ -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가 실제 전달과 일치
|
||||||
|
|
||||||
|
## 피드백 형식
|
||||||
|
|
||||||
|
- **치명적**: 반드시 수정 (보안, 빌드 실패)
|
||||||
|
- **경고**: 수정 권장 (성능, 유지보수성)
|
||||||
|
- **제안**: 선택적 개선
|
||||||
|
- **범위 밖 발견**: 리포트 외 파일 문제 (수정 금지, 보고만)
|
||||||
@@ -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 필터링 누락
|
||||||
|
- 리포트 데이터 직렬화/역직렬화 오류
|
||||||
|
|
||||||
|
## 출력 형식
|
||||||
|
|
||||||
|
각 이슈에 대해:
|
||||||
|
- 근본 원인 설명
|
||||||
|
- 수정 파일이 허용 범위 내인지 명시
|
||||||
|
- 구체적 코드 수정 (허용 범위 내만)
|
||||||
|
- 테스트 방법
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
## 리스크
|
||||||
|
- ...
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
|
||||||
|
## 검증 체크리스트
|
||||||
|
|
||||||
|
### 리포트 목록
|
||||||
|
- [ ] 테이블 데이터 정상 로딩
|
||||||
|
- [ ] 생성/수정/삭제 버튼 동작
|
||||||
|
- [ ] 검색/필터 동작
|
||||||
|
|
||||||
|
### 리포트 디자이너
|
||||||
|
- [ ] 캔버스 렌더링
|
||||||
|
- [ ] 컴포넌트 드래그&드롭
|
||||||
|
- [ ] 속성 패널 동작
|
||||||
|
- [ ] 프리뷰 모달
|
||||||
|
|
||||||
|
### 공통
|
||||||
|
- [ ] 스크롤 정상 작동
|
||||||
|
- [ ] 중첩 박스 없음
|
||||||
|
- [ ] 콘솔 에러 없음
|
||||||
@@ -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<ScreenDefinition>) => 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 경로 관리 필요 |
|
||||||
@@ -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` | `<ScreenContextProvider>` (props 없음, 1377행) | Wrapper에서 `useSearchParams`로 `menuObjid` 파싱 후 전달 |
|
||||||
|
| `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` | `<ScreenContextProvider>` (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) | 전체 보기 모달 |
|
||||||
@@ -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. **접근성**: `<DialogTitle className="sr-only">`, `<DialogDescription className="sr-only">` 반드시 포함
|
||||||
|
3. **헤더**: `px-6 py-4 border-b` + 아이콘(`w-4 h-4 text-blue-600`) + 제목(`text-base font-semibold`) + X 닫기 버튼
|
||||||
|
4. **탭**: shadcn `<Tabs>` 사용 금지 → `@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`
|
||||||
@@ -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에 포함시켜야 합니다.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
## 코드 리뷰 결과
|
||||||
|
|
||||||
|
### 치명적 (반드시 수정)
|
||||||
|
- [파일:라인] 설명
|
||||||
|
|
||||||
|
### 범위 밖 발견 (수정 금지, 보고만)
|
||||||
|
- [파일] 설명
|
||||||
|
```
|
||||||
@@ -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<string, any>;
|
||||||
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||||
|
companyCode?: string;
|
||||||
|
refreshKey?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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`로 관리:
|
||||||
|
- 선택된 컴포넌트
|
||||||
|
- 캔버스 상태
|
||||||
|
- 저장/불러오기
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# TableListComponent 상세 참조
|
||||||
|
|
||||||
|
## 주요 상태 (State)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 데이터
|
||||||
|
const [tableData, setTableData] = useState<any[]>([]);
|
||||||
|
const [filteredData, setFilteredData] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 편집
|
||||||
|
const [editingCell, setEditingCell] = useState<{
|
||||||
|
rowIndex: number; colIndex: number; columnName: string; originalValue: any;
|
||||||
|
} | null>(null);
|
||||||
|
const [pendingChanges, setPendingChanges] = useState<Map<string, Map<string, any>>>(new Map());
|
||||||
|
const [validationErrors, setValidationErrors] = useState<Map<string, Map<string, string>>>(new Map());
|
||||||
|
|
||||||
|
// 필터
|
||||||
|
const [headerFilters, setHeaderFilters] = useState<Map<string, Set<string>>>(new Map());
|
||||||
|
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
|
||||||
|
const [globalSearchText, setGlobalSearchText] = useState("");
|
||||||
|
|
||||||
|
// 컬럼
|
||||||
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||||
|
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// 선택/정렬/페이지네이션
|
||||||
|
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
|
||||||
|
const [sortBy, setSortBy] = useState<string>("");
|
||||||
|
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<string, number>;
|
||||||
|
columnOrder: string[];
|
||||||
|
sortBy: string; sortOrder: "asc" | "desc";
|
||||||
|
frozenColumns: string[];
|
||||||
|
columnVisibility: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 캐싱 전략
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const tableColumnCache = new Map<string, { columns: any[]; timestamp: number }>();
|
||||||
|
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";
|
||||||
|
```
|
||||||
@@ -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 수정 금지
|
||||||
@@ -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 정합성 확인
|
||||||
|
- 멀티테넌시 체크리스트 검증
|
||||||
@@ -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`
|
||||||
@@ -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": "<DB-ID>"},
|
||||||
|
"properties": {"이름": {"title": [{"text": {"content": "페이지 제목"}}]}},
|
||||||
|
"icon": "{\"type\": \"emoji\", \"emoji\": \"📘\"}"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 페이지 하위에 서브 페이지 생성
|
||||||
|
|
||||||
|
properties 키는 `"title"`을 사용한다 (DB 하위와 다름에 주의).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"parent": {"page_id": "<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를 사용했는가
|
||||||
@@ -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`에 새 계획 추가
|
||||||
@@ -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<Props> = ({ ... }) => {
|
||||||
|
// 6-1 ~ 6-8 순서 준수
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 필수 규칙
|
||||||
|
|
||||||
|
- 500줄 초과 금지 → 서브 컴포넌트 분리
|
||||||
|
- `any` 금지 → `Record<string, unknown>` 이상
|
||||||
|
- shadcn/ui 컴포넌트 우선 사용
|
||||||
|
- CSS 변수 사용 (하드코딩 색상 금지)
|
||||||
|
|
||||||
|
## 리포트 디자이너 컴포넌트 패턴
|
||||||
|
|
||||||
|
- `ReportDesignerContext`로 전역 상태 관리
|
||||||
|
- 속성 패널: `designer/properties/` 디렉토리
|
||||||
|
- 모달: `designer/modals/` 디렉토리
|
||||||
|
- 캔버스: `designer/ReportDesignerCanvas.tsx`
|
||||||
@@ -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', 순서, '회사코드');
|
||||||
|
```
|
||||||
@@ -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
|
||||||
|
<div className="relative overflow-auto" style={{ height: "450px" }}>
|
||||||
|
<Table noWrapper>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="bg-background sticky top-0 z-10 border-b shadow-[0_1px_0_0_rgb(0,0,0,0.1)]">
|
||||||
|
헤더
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>{/* 데이터 행들 */}</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
필수: `noWrapper`, `bg-background sticky top-0 z-10`, 고정 높이
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 스크롤 문제 상세 패턴 및 예시
|
||||||
|
|
||||||
|
## 패턴 A: 최상위 Fixed/Absolute 컨테이너
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="fixed inset-0 z-50 bg-background">
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center gap-4 border-b bg-background p-4">헤더</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<FlowEditor />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 패턴 B: 중첩된 Flex 컨테이너
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex h-full w-full" style={{ height: '100%', overflow: 'hidden' }}>
|
||||||
|
<div className="h-full w-[300px] border-r">사이드바</div>
|
||||||
|
<div className="relative flex-1">캔버스</div>
|
||||||
|
<div style={{ height: "100%", width: "350px", display: "flex", flexDirection: "column" }} className="border-l bg-white">
|
||||||
|
<PropertiesPanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 패턴 C: 스크롤 가능 영역
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<div style={{ flexShrink: 0, height: '64px' }} className="flex items-center justify-between border-b p-4">헤더</div>
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||||
|
<PropertiesContent />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 일반적인 실수
|
||||||
|
|
||||||
|
### 부모 높이 미확정
|
||||||
|
```tsx
|
||||||
|
// Bad
|
||||||
|
<div className="flex flex-col"><div className="flex-1"><ScrollComponent /></div></div>
|
||||||
|
// Good
|
||||||
|
<div className="flex flex-col h-screen"><div className="flex-1 overflow-hidden"><ScrollComponent /></div></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### minHeight: 0 누락
|
||||||
|
```tsx
|
||||||
|
// Bad
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto' }}>{/* 스크롤 안 됨 */}</div>
|
||||||
|
// Good
|
||||||
|
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>{/* 스크롤 됨 */}</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 최종 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
페이지 (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)
|
||||||
|
```
|
||||||
@@ -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 자동 감지
|
||||||
|
- 속성 패널
|
||||||
|
- 프리뷰 모달
|
||||||
|
|
||||||
|
### 공통
|
||||||
|
- 스크롤 정상
|
||||||
|
- 콘솔 에러 없음
|
||||||
@@ -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
|
||||||
@@ -221,6 +221,11 @@ docs/mes-reference/
|
|||||||
|
|
||||||
# 테스트 결과물
|
# 테스트 결과물
|
||||||
frontend/test-results/
|
frontend/test-results/
|
||||||
|
test-output/
|
||||||
|
test-results/
|
||||||
|
|
||||||
|
# 아카이브/백업 파일
|
||||||
|
*.tar.gz
|
||||||
|
|
||||||
# Cursor 설정
|
# Cursor 설정
|
||||||
.cursor/
|
.cursor/
|
||||||
|
|||||||
@@ -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. 키패드 모달에서 포장등록 버튼이 숨겨지는지 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 이전 완료 계획 (아카이브)
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>pop-dashboard 4가지 아이템 모드 완성 (완료)</summary>
|
|
||||||
|
|
||||||
- [x] groupBy UI 추가
|
|
||||||
- [x] xAxisColumn 입력 UI 추가
|
|
||||||
- [x] 통계카드 카테고리 설정 UI 추가
|
|
||||||
- [x] 차트 xAxisColumn 자동 보정 로직
|
|
||||||
- [x] 통계카드 카테고리별 필터 적용
|
|
||||||
- [x] SQL 빌더 방어 로직
|
|
||||||
- [x] refreshInterval 최소값 강제
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>POP 뷰어 스크롤 수정 (완료)</summary>
|
|
||||||
|
|
||||||
- [x] overflow-hidden 제거
|
|
||||||
- [x] overflow-auto 공통 적용
|
|
||||||
- [x] 일반 모드 min-h-full 추가
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>POP 뷰어 실제 컴포넌트 렌더링 (완료)</summary>
|
|
||||||
|
|
||||||
- [x] 뷰어 페이지에 레지스트리 초기화 import 추가
|
|
||||||
- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체
|
|
||||||
|
|
||||||
</details>
|
|
||||||
-1041
File diff suppressed because it is too large
Load Diff
-696
@@ -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`
|
|
||||||
@@ -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`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 |
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -3,25 +3,26 @@
|
|||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
#
|
#
|
||||||
# ⚠️ 주의: 이 파일은 Git에 커밋됩니다!
|
# ⚠️ 주의: 이 파일은 Git에 커밋됩니다!
|
||||||
# 팀원들이 동일한 API 키를 사용합니다.
|
# 실제 API 키는 .env 파일에 설정하세요.
|
||||||
|
# 여기에는 키 형식 예시만 기록합니다.
|
||||||
#
|
#
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|
||||||
# 한국은행 환율 API 키
|
# 한국은행 환율 API 키
|
||||||
# 발급: https://www.bok.or.kr/portal/openapi/OpenApiGuide.do
|
# 발급: https://www.bok.or.kr/portal/openapi/OpenApiGuide.do
|
||||||
BOK_API_KEY=OXIGPQXH68NUKVKL5KT9
|
BOK_API_KEY=your_bok_api_key_here
|
||||||
|
|
||||||
# 기상청 API Hub 키
|
# 기상청 API Hub 키
|
||||||
# 발급: https://apihub.kma.go.kr/
|
# 발급: https://apihub.kma.go.kr/
|
||||||
KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
KMA_API_KEY=your_kma_api_key_here
|
||||||
|
|
||||||
# ITS 국가교통정보센터 API 키
|
# ITS 국가교통정보센터 API 키
|
||||||
# 발급: https://www.its.go.kr/
|
# 발급: https://www.its.go.kr/
|
||||||
ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
ITS_API_KEY=your_its_api_key_here
|
||||||
|
|
||||||
# 한국도로공사 OpenOASIS API 키
|
# 한국도로공사 OpenOASIS API 키
|
||||||
# 발급: https://data.ex.co.kr/ (OpenOASIS 신청)
|
# 발급: https://data.ex.co.kr/ (OpenOASIS 신청)
|
||||||
EXWAY_API_KEY=7820214492
|
EXWAY_API_KEY=your_exway_api_key_here
|
||||||
|
|
||||||
# ExchangeRate API 키 (백업용, 선택사항)
|
# ExchangeRate API 키 (백업용, 선택사항)
|
||||||
# 발급: https://www.exchangerate-api.com/
|
# 발급: https://www.exchangerate-api.com/
|
||||||
|
|||||||
@@ -6,12 +6,12 @@
|
|||||||
### ✅ 작동 중인 API
|
### ✅ 작동 중인 API
|
||||||
|
|
||||||
1. **기상청 특보 API** (완벽 작동!)
|
1. **기상청 특보 API** (완벽 작동!)
|
||||||
- API 키: `ogdXr2e9T4iHV69nvV-IwA`
|
- API 키: `${KMA_API_KEY}`
|
||||||
- 상태: ✅ 14건 실시간 특보 수신 중
|
- 상태: ✅ 14건 실시간 특보 수신 중
|
||||||
- 제공 데이터: 대설/강풍/한파/태풍/폭염 특보
|
- 제공 데이터: 대설/강풍/한파/태풍/폭염 특보
|
||||||
|
|
||||||
2. **한국은행 환율 API** (완벽 작동!)
|
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}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 문제 상황
|
### 문제 상황
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
## ✅ 완벽 작동 중
|
## ✅ 완벽 작동 중
|
||||||
|
|
||||||
### 1. 기상청 API Hub
|
### 1. 기상청 API Hub
|
||||||
- **API 키**: `ogdXr2e9T4iHV69nvV-IwA`
|
- **API 키**: `${KMA_API_KEY}`
|
||||||
- **상태**: ✅ 14건 실시간 특보 수신 중
|
- **상태**: ✅ 14건 실시간 특보 수신 중
|
||||||
- **제공 데이터**: 대설/강풍/한파/태풍/폭염 특보
|
- **제공 데이터**: 대설/강풍/한파/태풍/폭염 특보
|
||||||
- **코드 위치**: `backend-node/src/services/riskAlertService.ts`
|
- **코드 위치**: `backend-node/src/services/riskAlertService.ts`
|
||||||
|
|
||||||
### 2. 한국은행 환율 API
|
### 2. 한국은행 환율 API
|
||||||
- **API 키**: `OXIGPQXH68NUKVKL5KT9`
|
- **API 키**: `${BOK_API_KEY}`
|
||||||
- **상태**: ✅ 환율 위젯 작동 중
|
- **상태**: ✅ 환율 위젯 작동 중
|
||||||
- **제공 데이터**: USD/EUR/JPY/CNY 환율
|
- **제공 데이터**: USD/EUR/JPY/CNY 환율
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
## ⚠️ 연동 대기 중
|
## ⚠️ 연동 대기 중
|
||||||
|
|
||||||
### 3. 한국도로공사 OpenOASIS API
|
### 3. 한국도로공사 OpenOASIS API
|
||||||
- **API 키**: `7820214492`
|
- **API 키**: `${EXWAY_API_KEY}`
|
||||||
- **상태**: ❌ 엔드포인트 URL 불명
|
- **상태**: ❌ 엔드포인트 URL 불명
|
||||||
- **문제**:
|
- **문제**:
|
||||||
- 발급 이메일에 사용법 없음
|
- 발급 이메일에 사용법 없음
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
시스템 장애: 070-8656-8771
|
시스템 장애: 070-8656-8771
|
||||||
|
|
||||||
문의 내용:
|
문의 내용:
|
||||||
"OpenOASIS API 인증키(7820214492)를 발급받았는데
|
"OpenOASIS API 인증키(${EXWAY_API_KEY})를 발급받았는데
|
||||||
사용 방법과 엔드포인트 URL을 알려주세요.
|
사용 방법과 엔드포인트 URL을 알려주세요.
|
||||||
- 돌발상황정보 API
|
- 돌발상황정보 API
|
||||||
- 교통사고 정보
|
- 교통사고 정보
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 4. 국토교통부 ITS API
|
### 4. 국토교통부 ITS API
|
||||||
- **API 키**: `d6b9befec3114d648284674b8fddcc32`
|
- **API 키**: `${ITS_API_KEY}`
|
||||||
- **상태**: ❌ 엔드포인트 URL 불명
|
- **상태**: ❌ 엔드포인트 URL 불명
|
||||||
- **승인 API**:
|
- **승인 API**:
|
||||||
- 교통소통정보
|
- 교통소통정보
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
이메일: its@ex.co.kr
|
이메일: its@ex.co.kr
|
||||||
|
|
||||||
문의 내용:
|
문의 내용:
|
||||||
"ITS API 인증키(d6b9befec3114d648284674b8fddcc32)를
|
"ITS API 인증키(${ITS_API_KEY})를
|
||||||
발급받았는데 매뉴얼에 엔드포인트 URL이 없습니다.
|
발급받았는데 매뉴얼에 엔드포인트 URL이 없습니다.
|
||||||
돌발상황정보 API의 정확한 URL과 파라미터를
|
돌발상황정보 API의 정확한 URL과 파라미터를
|
||||||
알려주세요."
|
알려주세요."
|
||||||
@@ -88,8 +88,8 @@
|
|||||||
### 연동 방법
|
### 연동 방법
|
||||||
```bash
|
```bash
|
||||||
# .env 파일에 추가
|
# .env 파일에 추가
|
||||||
ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
ITS_API_KEY=${ITS_API_KEY}
|
||||||
EXWAY_API_KEY=7820214492
|
EXWAY_API_KEY=${EXWAY_API_KEY}
|
||||||
|
|
||||||
# 백엔드 재시작
|
# 백엔드 재시작
|
||||||
docker restart pms-backend-mac
|
docker restart pms-backend-mac
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ npm install
|
|||||||
`.env` 파일을 생성하고 다음 내용을 추가하세요:
|
`.env` 파일을 생성하고 다음 내용을 추가하세요:
|
||||||
|
|
||||||
```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_SECRET="your-super-secret-jwt-key-change-in-production"
|
||||||
JWT_EXPIRES_IN="24h"
|
JWT_EXPIRES_IN="24h"
|
||||||
PORT=8080
|
PORT=8080
|
||||||
|
|||||||
@@ -19,19 +19,19 @@ cp .env.shared .env
|
|||||||
|
|
||||||
### ✅ 한국은행 환율 API
|
### ✅ 한국은행 환율 API
|
||||||
- 용도: 환율 정보 조회
|
- 용도: 환율 정보 조회
|
||||||
- 키: `OXIGPQXH68NUKVKL5KT9`
|
- 키: `${BOK_API_KEY}`
|
||||||
|
|
||||||
### ✅ 기상청 API Hub
|
### ✅ 기상청 API Hub
|
||||||
- 용도: 날씨특보, 기상정보
|
- 용도: 날씨특보, 기상정보
|
||||||
- 키: `ogdXr2e9T4iHV69nvV-IwA`
|
- 키: `${KMA_API_KEY}`
|
||||||
|
|
||||||
### ✅ ITS 국가교통정보센터
|
### ✅ ITS 국가교통정보센터
|
||||||
- 용도: 교통사고, 도로공사 정보
|
- 용도: 교통사고, 도로공사 정보
|
||||||
- 키: `d6b9befec3114d648284674b8fddcc32`
|
- 키: `${ITS_API_KEY}`
|
||||||
|
|
||||||
### ✅ 한국도로공사 OpenOASIS
|
### ✅ 한국도로공사 OpenOASIS
|
||||||
- 용도: 고속도로 교통정보
|
- 용도: 고속도로 교통정보
|
||||||
- 키: `7820214492`
|
- 키: `${EXWAY_API_KEY}`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -27,11 +27,11 @@ async function addExternalDbConnection() {
|
|||||||
name: "운영_외부_PostgreSQL",
|
name: "운영_외부_PostgreSQL",
|
||||||
description: "운영용 외부 PostgreSQL 데이터베이스",
|
description: "운영용 외부 PostgreSQL 데이터베이스",
|
||||||
dbType: "postgresql",
|
dbType: "postgresql",
|
||||||
host: "39.117.244.52",
|
host: process.env.EXT_DB_HOST || "localhost",
|
||||||
port: 11132,
|
port: parseInt(process.env.EXT_DB_PORT || "5432"),
|
||||||
databaseName: "plm",
|
databaseName: process.env.EXT_DB_NAME || "vexplor_dev",
|
||||||
username: "postgres",
|
username: process.env.EXT_DB_USER || "postgres",
|
||||||
password: "ph0909!!", // 이 값은 암호화되어 저장됩니다
|
password: process.env.EXT_DB_PASSWORD || "", // 환경변수로 전달
|
||||||
sslEnabled: false,
|
sslEnabled: false,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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();
|
|
||||||
@@ -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<string, string> = {
|
|
||||||
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();
|
|
||||||
@@ -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();
|
|
||||||
|
|
||||||
@@ -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();
|
|
||||||
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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();
|
|
||||||
@@ -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<string, string> = {
|
|
||||||
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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
|
|
||||||
@@ -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();
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ const config: Config = {
|
|||||||
|
|
||||||
// JWT 설정
|
// JWT 설정
|
||||||
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",
|
expiresIn: process.env.JWT_EXPIRES_IN || "24h",
|
||||||
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
|
refreshExpiresIn: process.env.JWT_REFRESH_EXPIRES_IN || "7d",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -286,6 +286,11 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
|||||||
received_qty = CAST(
|
received_qty = CAST(
|
||||||
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text
|
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()
|
updated_date = NOW()
|
||||||
WHERE id = $2 AND company_code = $3`,
|
WHERE id = $2 AND company_code = $3`,
|
||||||
[item.inbound_qty || 0, item.source_id, companyCode]
|
[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) {
|
export async function getItems(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
const companyCode = req.user!.companyCode;
|
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 currentPage = Math.max(1, Number(page) || 1);
|
||||||
const limit = Math.min(500, Math.max(1, Number(pageSize) || 20));
|
const limit = Math.min(500, Math.max(1, Number(pageSize) || 20));
|
||||||
const offset = (currentPage - 1) * limit;
|
const offset = (currentPage - 1) * limit;
|
||||||
@@ -800,6 +805,12 @@ export async function getItems(req: AuthenticatedRequest, res: Response) {
|
|||||||
paramIdx++;
|
paramIdx++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (division) {
|
||||||
|
conditions.push(`division ILIKE $${paramIdx}`);
|
||||||
|
params.push(`%${division}%`);
|
||||||
|
paramIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
const whereClause = conditions.join(" AND ");
|
const whereClause = conditions.join(" AND ");
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
/**
|
import { Response, NextFunction } from "express";
|
||||||
* 리포트 관리 컨트롤러
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import reportService from "../services/reportService";
|
import reportService from "../services/reportService";
|
||||||
import {
|
import {
|
||||||
CreateReportRequest,
|
CreateReportRequest,
|
||||||
UpdateReportRequest,
|
UpdateReportRequest,
|
||||||
SaveLayoutRequest,
|
SaveLayoutRequest,
|
||||||
CreateTemplateRequest,
|
CreateTemplateRequest,
|
||||||
|
GetReportsParams,
|
||||||
} from "../types/report";
|
} from "../types/report";
|
||||||
|
import { AuthenticatedRequest } from "../types/auth";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import {
|
import {
|
||||||
@@ -35,92 +34,91 @@ import {
|
|||||||
import { WatermarkConfig } from "../types/report";
|
import { WatermarkConfig } from "../types/report";
|
||||||
import bwipjs from "bwip-js";
|
import bwipjs from "bwip-js";
|
||||||
|
|
||||||
|
function getUserInfo(req: AuthenticatedRequest) {
|
||||||
|
return {
|
||||||
|
userId: req.user?.userId || "SYSTEM",
|
||||||
|
companyCode: req.user?.companyCode || "*",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class ReportController {
|
export class ReportController {
|
||||||
/**
|
async getReports(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 리포트 목록 조회
|
|
||||||
* GET /api/admin/reports
|
|
||||||
*/
|
|
||||||
async getReports(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { companyCode } = getUserInfo(req);
|
||||||
const {
|
const {
|
||||||
page = "1",
|
page = "1", limit = "20", searchText = "", searchField,
|
||||||
limit = "20",
|
startDate, endDate, reportType = "", useYn = "Y",
|
||||||
searchText = "",
|
sortBy = "created_at", sortOrder = "DESC",
|
||||||
reportType = "",
|
|
||||||
useYn = "Y",
|
|
||||||
sortBy = "created_at",
|
|
||||||
sortOrder = "DESC",
|
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
||||||
const result = await reportService.getReports({
|
const result = await reportService.getReports({
|
||||||
page: parseInt(page as string, 10),
|
page: parseInt(page as string, 10),
|
||||||
limit: parseInt(limit as string, 10),
|
limit: parseInt(limit as string, 10),
|
||||||
searchText: searchText as string,
|
searchText: searchText as string,
|
||||||
|
searchField: searchField as GetReportsParams["searchField"],
|
||||||
|
startDate: startDate as string | undefined,
|
||||||
|
endDate: endDate as string | undefined,
|
||||||
reportType: reportType as string,
|
reportType: reportType as string,
|
||||||
useYn: useYn as string,
|
useYn: useYn as string,
|
||||||
sortBy: sortBy as string,
|
sortBy: sortBy as string,
|
||||||
sortOrder: sortOrder as "ASC" | "DESC",
|
sortOrder: sortOrder as "ASC" | "DESC",
|
||||||
});
|
}, companyCode);
|
||||||
|
|
||||||
return res.json({
|
return res.json({ success: true, data: result });
|
||||||
success: true,
|
|
||||||
data: result,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getReportById(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 리포트 상세 조회
|
|
||||||
* GET /api/admin/reports/:reportId
|
|
||||||
*/
|
|
||||||
async getReportById(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { companyCode } = getUserInfo(req);
|
||||||
const { reportId } = req.params;
|
const { reportId } = req.params;
|
||||||
|
|
||||||
const report = await reportService.getReportById(reportId);
|
const report = await reportService.getReportById(reportId, companyCode);
|
||||||
|
|
||||||
if (!report) {
|
if (!report) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
|
||||||
success: false,
|
|
||||||
message: "리포트를 찾을 수 없습니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({ success: true, data: report });
|
||||||
success: true,
|
|
||||||
data: report,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getReportsByMenuObjid(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 리포트 생성
|
|
||||||
* POST /api/admin/reports
|
|
||||||
*/
|
|
||||||
async createReport(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
const data: CreateReportRequest = req.body;
|
const { companyCode } = getUserInfo(req);
|
||||||
const userId = (req as any).user?.userId || "SYSTEM";
|
const { menuObjid } = req.params;
|
||||||
|
const menuObjidNum = parseInt(menuObjid, 10);
|
||||||
|
|
||||||
// 필수 필드 검증
|
if (isNaN(menuObjidNum)) {
|
||||||
if (!data.reportNameKor || !data.reportType) {
|
return res.status(400).json({ success: false, message: "menuObjid는 숫자여야 합니다." });
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "리포트명과 리포트 타입은 필수입니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
const reportId = await reportService.createReport(data, userId);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: { reportId },
|
||||||
reportId,
|
|
||||||
},
|
|
||||||
message: "리포트가 생성되었습니다.",
|
message: "리포트가 생성되었습니다.",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -128,83 +126,56 @@ export class ReportController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async updateReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 리포트 수정
|
|
||||||
* PUT /api/admin/reports/:reportId
|
|
||||||
*/
|
|
||||||
async updateReport(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { userId, companyCode } = getUserInfo(req);
|
||||||
const { reportId } = req.params;
|
const { reportId } = req.params;
|
||||||
const data: UpdateReportRequest = req.body;
|
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) {
|
if (!success) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: "수정할 내용이 없습니다." });
|
||||||
success: false,
|
|
||||||
message: "수정할 내용이 없습니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({ success: true, message: "리포트가 수정되었습니다." });
|
||||||
success: true,
|
|
||||||
message: "리포트가 수정되었습니다.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async deleteReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 리포트 삭제
|
|
||||||
* DELETE /api/admin/reports/:reportId
|
|
||||||
*/
|
|
||||||
async deleteReport(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { companyCode } = getUserInfo(req);
|
||||||
const { reportId } = req.params;
|
const { reportId } = req.params;
|
||||||
|
|
||||||
const success = await reportService.deleteReport(reportId);
|
const success = await reportService.deleteReport(reportId, companyCode);
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
|
||||||
success: false,
|
|
||||||
message: "리포트를 찾을 수 없습니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({ success: true, message: "리포트가 삭제되었습니다." });
|
||||||
success: true,
|
|
||||||
message: "리포트가 삭제되었습니다.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async copyReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 리포트 복사
|
|
||||||
* POST /api/admin/reports/:reportId/copy
|
|
||||||
*/
|
|
||||||
async copyReport(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { userId, companyCode } = getUserInfo(req);
|
||||||
const { reportId } = req.params;
|
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) {
|
if (!newReportId) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
|
||||||
success: false,
|
|
||||||
message: "리포트를 찾을 수 없습니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: { reportId: newReportId },
|
||||||
reportId: newReportId,
|
|
||||||
},
|
|
||||||
message: "리포트가 복사되었습니다.",
|
message: "리포트가 복사되었습니다.",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -212,132 +183,92 @@ export class ReportController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 레이아웃 조회
|
|
||||||
* GET /api/admin/reports/:reportId/layout
|
|
||||||
*/
|
|
||||||
async getLayout(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { companyCode } = getUserInfo(req);
|
||||||
const { reportId } = req.params;
|
const { reportId } = req.params;
|
||||||
|
|
||||||
const layout = await reportService.getLayout(reportId);
|
const layout = await reportService.getLayout(reportId, companyCode);
|
||||||
|
|
||||||
if (!layout) {
|
if (!layout) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({ success: false, message: "레이아웃을 찾을 수 없습니다." });
|
||||||
success: false,
|
|
||||||
message: "레이아웃을 찾을 수 없습니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// components 컬럼에서 JSON 파싱
|
const storedData = layout.components;
|
||||||
const parsedComponents = layout.components
|
|
||||||
? JSON.parse(layout.components)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
let layoutData;
|
let layoutData;
|
||||||
// 새 구조 (layoutConfig.pages)인지 확인
|
|
||||||
if (
|
if (
|
||||||
parsedComponents &&
|
storedData &&
|
||||||
parsedComponents.pages &&
|
typeof storedData === "object" &&
|
||||||
Array.isArray(parsedComponents.pages)
|
!Array.isArray(storedData) &&
|
||||||
|
Array.isArray((storedData as Record<string, unknown>).pages)
|
||||||
) {
|
) {
|
||||||
// pages 배열을 직접 포함하여 반환
|
const parsed = storedData as Record<string, unknown>;
|
||||||
layoutData = {
|
layoutData = {
|
||||||
...layout,
|
...layout,
|
||||||
pages: parsedComponents.pages,
|
pages: parsed.pages,
|
||||||
components: [], // 호환성을 위해 빈 배열
|
watermark: parsed.watermark,
|
||||||
|
components: storedData,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// 기존 구조: components 배열
|
layoutData = { ...layout, components: storedData || [] };
|
||||||
layoutData = {
|
|
||||||
...layout,
|
|
||||||
components: parsedComponents || [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({ success: true, data: layoutData });
|
||||||
success: true,
|
|
||||||
data: layoutData,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async saveLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 레이아웃 저장
|
|
||||||
* PUT /api/admin/reports/:reportId/layout
|
|
||||||
*/
|
|
||||||
async saveLayout(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { userId, companyCode } = getUserInfo(req);
|
||||||
const { reportId } = req.params;
|
const { reportId } = req.params;
|
||||||
const data: SaveLayoutRequest = req.body;
|
const data: SaveLayoutRequest = req.body;
|
||||||
const userId = (req as any).user?.userId || "SYSTEM";
|
|
||||||
|
|
||||||
// 필수 필드 검증 (페이지 기반 구조)
|
if (!data.layoutConfig?.pages?.length) {
|
||||||
if (
|
return res.status(400).json({ success: false, message: "레이아웃 설정이 필요합니다." });
|
||||||
!data.layoutConfig ||
|
|
||||||
!data.layoutConfig.pages ||
|
|
||||||
data.layoutConfig.pages.length === 0
|
|
||||||
) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: "레이아웃 설정이 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await reportService.saveLayout(reportId, data, userId);
|
await reportService.saveLayout(reportId, data, userId, companyCode);
|
||||||
|
return res.json({ success: true, message: "레이아웃이 저장되었습니다." });
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
message: "레이아웃이 저장되었습니다.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getTemplates(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 템플릿 목록 조회
|
|
||||||
* GET /api/admin/reports/templates
|
|
||||||
*/
|
|
||||||
async getTemplates(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
const templates = await reportService.getTemplates();
|
const templates = await reportService.getTemplates();
|
||||||
|
return res.json({ success: true, data: templates });
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data: templates,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getCategories(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 템플릿 생성
|
|
||||||
* POST /api/admin/reports/templates
|
|
||||||
*/
|
|
||||||
async createTemplate(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
const data: CreateTemplateRequest = req.body;
|
const categories = await reportService.getCategories();
|
||||||
const userId = (req as any).user?.userId || "SYSTEM";
|
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) {
|
if (!data.templateNameKor || !data.templateType) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: "템플릿명과 템플릿 타입은 필수입니다." });
|
||||||
success: false,
|
|
||||||
message: "템플릿명과 템플릿 타입은 필수입니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateId = await reportService.createTemplate(data, userId);
|
const templateId = await reportService.createTemplate(data, userId);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: { templateId },
|
||||||
templateId,
|
|
||||||
},
|
|
||||||
message: "템플릿이 생성되었습니다.",
|
message: "템플릿이 생성되었습니다.",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -345,37 +276,23 @@ export class ReportController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async saveAsTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 현재 리포트를 템플릿으로 저장
|
|
||||||
* POST /api/admin/reports/:reportId/save-as-template
|
|
||||||
*/
|
|
||||||
async saveAsTemplate(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
|
const { userId } = getUserInfo(req);
|
||||||
const { reportId } = req.params;
|
const { reportId } = req.params;
|
||||||
const { templateNameKor, templateNameEng, description } = req.body;
|
const { templateNameKor, templateNameEng, description } = req.body;
|
||||||
const userId = (req as any).user?.userId || "SYSTEM";
|
|
||||||
|
|
||||||
// 필수 필드 검증
|
|
||||||
if (!templateNameKor) {
|
if (!templateNameKor) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
|
||||||
success: false,
|
|
||||||
message: "템플릿명은 필수입니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateId = await reportService.saveAsTemplate(
|
const templateId = await reportService.saveAsTemplate(
|
||||||
reportId,
|
reportId, templateNameKor, templateNameEng, description, userId
|
||||||
templateNameKor,
|
|
||||||
templateNameEng,
|
|
||||||
description,
|
|
||||||
userId
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: { templateId },
|
||||||
templateId,
|
|
||||||
},
|
|
||||||
message: "템플릿이 저장되었습니다.",
|
message: "템플릿이 저장되었습니다.",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -383,39 +300,20 @@ export class ReportController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async createTemplateFromLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
|
|
||||||
* POST /api/admin/reports/templates/create-from-layout
|
|
||||||
*/
|
|
||||||
async createTemplateFromLayout(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
|
const { userId } = getUserInfo(req);
|
||||||
const {
|
const {
|
||||||
templateNameKor,
|
templateNameKor, templateNameEng, templateType,
|
||||||
templateNameEng,
|
description, layoutConfig, defaultQueries = [],
|
||||||
templateType,
|
|
||||||
description,
|
|
||||||
layoutConfig,
|
|
||||||
defaultQueries = [],
|
|
||||||
} = req.body;
|
} = req.body;
|
||||||
const userId = (req as any).user?.userId || "SYSTEM";
|
|
||||||
|
|
||||||
// 필수 필드 검증
|
|
||||||
if (!templateNameKor) {
|
if (!templateNameKor) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
|
||||||
success: false,
|
|
||||||
message: "템플릿명은 필수입니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!layoutConfig) {
|
if (!layoutConfig) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: "레이아웃 설정은 필수입니다." });
|
||||||
success: false,
|
|
||||||
message: "레이아웃 설정은 필수입니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateId = await reportService.createTemplateFromLayout(
|
const templateId = await reportService.createTemplateFromLayout(
|
||||||
@@ -440,78 +338,47 @@ export class ReportController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async deleteTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 템플릿 삭제
|
|
||||||
* DELETE /api/admin/reports/templates/:templateId
|
|
||||||
*/
|
|
||||||
async deleteTemplate(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
const { templateId } = req.params;
|
const { templateId } = req.params;
|
||||||
|
|
||||||
const success = await reportService.deleteTemplate(templateId);
|
const success = await reportService.deleteTemplate(templateId);
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({ success: false, message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다." });
|
||||||
success: false,
|
|
||||||
message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
return res.json({ success: true, message: "템플릿이 삭제되었습니다." });
|
||||||
success: true,
|
|
||||||
message: "템플릿이 삭제되었습니다.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(error);
|
return next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async executeQuery(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 쿼리 실행
|
|
||||||
* POST /api/admin/reports/:reportId/queries/:queryId/execute
|
|
||||||
*/
|
|
||||||
async executeQuery(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
const { reportId, queryId } = req.params;
|
const { reportId, queryId } = req.params;
|
||||||
const { parameters = {}, sqlQuery, externalConnectionId } = req.body;
|
const { parameters = {}, sqlQuery, externalConnectionId } = req.body;
|
||||||
|
|
||||||
const result = await reportService.executeQuery(
|
const result = await reportService.executeQuery(
|
||||||
reportId,
|
reportId, queryId, parameters, sqlQuery, externalConnectionId
|
||||||
queryId,
|
|
||||||
parameters,
|
|
||||||
sqlQuery,
|
|
||||||
externalConnectionId
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return res.json({
|
return res.json({ success: true, data: result });
|
||||||
success: true,
|
} catch (error: unknown) {
|
||||||
data: result,
|
const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다.";
|
||||||
});
|
return res.status(400).json({ success: false, message });
|
||||||
} catch (error: any) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: error.message || "쿼리 실행에 실패했습니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getExternalConnections(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 외부 DB 연결 목록 조회 (활성화된 것만)
|
|
||||||
* GET /api/admin/reports/external-connections
|
|
||||||
*/
|
|
||||||
async getExternalConnections(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
|
const { companyCode } = getUserInfo(req);
|
||||||
const { ExternalDbConnectionService } = await import(
|
const { ExternalDbConnectionService } = await import(
|
||||||
"../services/externalDbConnectionService"
|
"../services/externalDbConnectionService"
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await ExternalDbConnectionService.getConnections({
|
const result = await ExternalDbConnectionService.getConnections({
|
||||||
is_active: "Y",
|
is_active: "Y",
|
||||||
company_code: req.body.companyCode || "",
|
company_code: companyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json(result);
|
return res.json(result);
|
||||||
@@ -520,52 +387,34 @@ export class ReportController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async uploadImage(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 이미지 파일 업로드
|
|
||||||
* POST /api/admin/reports/upload-image
|
|
||||||
*/
|
|
||||||
async uploadImage(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({ success: false, message: "이미지 파일이 필요합니다." });
|
||||||
success: false,
|
|
||||||
message: "이미지 파일이 필요합니다.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const companyCode = req.body.companyCode || "SYSTEM";
|
const { companyCode } = getUserInfo(req);
|
||||||
const file = req.file;
|
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)) {
|
if (!fs.existsSync(uploadDir)) {
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 고유한 파일명 생성 (타임스탬프 + 원본 파일명)
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_");
|
const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||||
const fileName = `${timestamp}_${safeFileName}`;
|
const fileName = `${timestamp}_${safeFileName}`;
|
||||||
const filePath = path.join(uploadDir, fileName);
|
const filePath = path.join(uploadDir, fileName);
|
||||||
|
|
||||||
// 파일 저장
|
|
||||||
fs.writeFileSync(filePath, file.buffer);
|
fs.writeFileSync(filePath, file.buffer);
|
||||||
|
|
||||||
// 웹에서 접근 가능한 URL 반환
|
|
||||||
const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`;
|
const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`;
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
fileName,
|
fileName, fileUrl,
|
||||||
fileUrl,
|
|
||||||
originalName: file.originalname,
|
originalName: file.originalname,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
mimeType: file.mimetype,
|
mimeType: file.mimetype,
|
||||||
@@ -576,11 +425,7 @@ export class ReportController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async exportToWord(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||||
* 컴포넌트 데이터를 WORD(DOCX)로 변환
|
|
||||||
* POST /api/admin/reports/export-word
|
|
||||||
*/
|
|
||||||
async exportToWord(req: Request, res: Response, next: NextFunction) {
|
|
||||||
try {
|
try {
|
||||||
const { layoutConfig, queryResults, fileName = "리포트" } = req.body;
|
const { layoutConfig, queryResults, fileName = "리포트" } = req.body;
|
||||||
|
|
||||||
@@ -591,22 +436,15 @@ export class ReportController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// mm를 twip으로 변환
|
|
||||||
const mmToTwip = (mm: number) => convertMillimetersToTwip(mm);
|
const mmToTwip = (mm: number) => convertMillimetersToTwip(mm);
|
||||||
|
const MM_TO_PX = 4; // 프론트엔드와 동일, 1mm = 56.692913386 twip (docx)
|
||||||
// 프론트엔드와 동일한 MM_TO_PX 상수 (캔버스에서 mm를 px로 변환할 때 사용하는 값)
|
|
||||||
const MM_TO_PX = 4;
|
|
||||||
// 1mm = 56.692913386 twip (docx 라이브러리 기준)
|
|
||||||
// px를 twip으로 변환: px -> mm -> twip
|
|
||||||
const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386);
|
const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386);
|
||||||
|
|
||||||
// 쿼리 결과 맵
|
|
||||||
const queryResultsMap: Record<
|
const queryResultsMap: Record<
|
||||||
string,
|
string,
|
||||||
{ fields: string[]; rows: Record<string, unknown>[] }
|
{ fields: string[]; rows: Record<string, unknown>[] }
|
||||||
> = queryResults || {};
|
> = queryResults || {};
|
||||||
|
|
||||||
// 컴포넌트 값 가져오기
|
|
||||||
const getComponentValue = (component: any): string => {
|
const getComponentValue = (component: any): string => {
|
||||||
if (component.queryId && component.fieldName) {
|
if (component.queryId && component.fieldName) {
|
||||||
const queryResult = queryResultsMap[component.queryId];
|
const queryResult = queryResultsMap[component.queryId];
|
||||||
@@ -621,11 +459,9 @@ export class ReportController {
|
|||||||
return component.defaultValue || "";
|
return component.defaultValue || "";
|
||||||
};
|
};
|
||||||
|
|
||||||
// px → half-point 변환 (1px = 0.75pt, Word는 half-pt 단위 사용)
|
// px → half-point (1px = 0.75pt, px * 1.5)
|
||||||
// px * 0.75 * 2 = px * 1.5
|
|
||||||
const pxToHalfPt = (px: number) => Math.round(px * 1.5);
|
const pxToHalfPt = (px: number) => Math.round(px * 1.5);
|
||||||
|
|
||||||
// 셀 내용 생성 헬퍼 함수 (가로 배치용)
|
|
||||||
const createCellContent = (
|
const createCellContent = (
|
||||||
component: any,
|
component: any,
|
||||||
displayValue: string,
|
displayValue: string,
|
||||||
@@ -1557,7 +1393,7 @@ export class ReportController {
|
|||||||
const base64 = png.toString("base64");
|
const base64 = png.toString("base64");
|
||||||
return `data:image/png;base64,${base64}`;
|
return `data:image/png;base64,${base64}`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("바코드 생성 오류:", error);
|
logger.error("바코드 생성 오류:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1891,7 +1727,7 @@ export class ReportController {
|
|||||||
children.push(paragraph);
|
children.push(paragraph);
|
||||||
lastBottomY = adjustedY + component.height;
|
lastBottomY = adjustedY + component.height;
|
||||||
} catch (imgError) {
|
} catch (imgError) {
|
||||||
console.error("이미지 처리 오류:", imgError);
|
logger.error("이미지 처리 오류:", imgError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2005,7 +1841,7 @@ export class ReportController {
|
|||||||
});
|
});
|
||||||
children.push(paragraph);
|
children.push(paragraph);
|
||||||
} catch (imgError) {
|
} catch (imgError) {
|
||||||
console.error("서명 이미지 오류:", imgError);
|
logger.error("서명 이미지 오류:", imgError);
|
||||||
textRuns.push(
|
textRuns.push(
|
||||||
new TextRun({
|
new TextRun({
|
||||||
text: "_".repeat(20),
|
text: "_".repeat(20),
|
||||||
@@ -2083,7 +1919,7 @@ export class ReportController {
|
|||||||
});
|
});
|
||||||
children.push(paragraph);
|
children.push(paragraph);
|
||||||
} catch (imgError) {
|
} catch (imgError) {
|
||||||
console.error("도장 이미지 오류:", imgError);
|
logger.error("도장 이미지 오류:", imgError);
|
||||||
textRuns.push(
|
textRuns.push(
|
||||||
new TextRun({
|
new TextRun({
|
||||||
text: "(인)",
|
text: "(인)",
|
||||||
@@ -2886,7 +2722,7 @@ export class ReportController {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch (imgError) {
|
} catch (imgError) {
|
||||||
console.error("바코드 이미지 오류:", imgError);
|
logger.error("바코드 이미지 오류:", imgError);
|
||||||
// 바코드 이미지 생성 실패 시 텍스트로 대체
|
// 바코드 이미지 생성 실패 시 텍스트로 대체
|
||||||
const barcodeValue = component.barcodeValue || "BARCODE";
|
const barcodeValue = component.barcodeValue || "BARCODE";
|
||||||
children.push(
|
children.push(
|
||||||
@@ -3164,13 +3000,57 @@ export class ReportController {
|
|||||||
|
|
||||||
return res.send(docxBuffer);
|
return res.send(docxBuffer);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("WORD 변환 오류:", error);
|
logger.error("WORD 변환 오류:", error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: error.message || "WORD 변환에 실패했습니다.",
|
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();
|
export default new ReportController();
|
||||||
|
|||||||
@@ -7,9 +7,21 @@ import { getPool } from "../database/db";
|
|||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
import { numberingRuleService } from "../services/numberingRuleService";
|
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 기준 행 반환) ───
|
// ─── 작업지시 목록 조회 (detail 기준 행 반환) ───
|
||||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
await ensureDetailRoutingColumn();
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
|
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
|
||||||
|
|
||||||
@@ -72,6 +84,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
|||||||
d.part_code,
|
d.part_code,
|
||||||
d.source_table,
|
d.source_table,
|
||||||
d.source_id,
|
d.source_id,
|
||||||
|
d.routing_version_id AS detail_routing_version_id,
|
||||||
COALESCE(itm.item_name, '') AS item_name,
|
COALESCE(itm.item_name, '') AS item_name,
|
||||||
COALESCE(itm.size, '') AS item_spec,
|
COALESCE(itm.size, '') AS item_spec,
|
||||||
COALESCE(e.equipment_name, '') AS equipment_name,
|
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) {
|
export async function save(req: AuthenticatedRequest, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
await ensureDetailRoutingColumn();
|
||||||
const companyCode = req.user!.companyCode;
|
const companyCode = req.user!.companyCode;
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body;
|
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) {
|
for (const item of items) {
|
||||||
await client.query(
|
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)`,
|
`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||"", userId]
|
[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]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ router.get("/templates", (req, res, next) =>
|
|||||||
router.post("/templates", (req, res, next) =>
|
router.post("/templates", (req, res, next) =>
|
||||||
reportController.createTemplate(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) =>
|
router.post("/templates/create-from-layout", (req, res, next) =>
|
||||||
reportController.createTemplateFromLayout(req, res, next)
|
reportController.createTemplateFromLayout(req, res, next)
|
||||||
@@ -61,6 +66,17 @@ router.post("/export-word", (req, res, next) =>
|
|||||||
reportController.exportToWord(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) =>
|
router.get("/", (req, res, next) =>
|
||||||
reportController.getReports(req, res, next)
|
reportController.getReports(req, res, next)
|
||||||
@@ -71,6 +87,11 @@ router.post("/", (req, res, next) =>
|
|||||||
reportController.createReport(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) =>
|
router.post("/:reportId/copy", (req, res, next) =>
|
||||||
reportController.copyReport(req, res, next)
|
reportController.copyReport(req, res, next)
|
||||||
|
|||||||
@@ -1195,6 +1195,10 @@ export class DynamicFormService {
|
|||||||
|
|
||||||
const updatedRecord = Array.isArray(result) ? result[0] : result;
|
const updatedRecord = Array.isArray(result) ? result[0] : result;
|
||||||
|
|
||||||
|
if (!updatedRecord) {
|
||||||
|
throw new Error(`업데이트 대상 레코드를 찾을 수 없습니다. (id: ${id}, 테이블: ${tableName})`);
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 조건부 연결 실행 (UPDATE 트리거)
|
// 🔥 조건부 연결 실행 (UPDATE 트리거)
|
||||||
try {
|
try {
|
||||||
if (company_code) {
|
if (company_code) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -202,7 +202,7 @@ export class RiskAlertService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2순위: 한국도로공사 API (현재 차단됨)
|
// 2순위: 한국도로공사 API (현재 차단됨)
|
||||||
const exwayApiKey = process.env.EXWAY_API_KEY || '7820214492';
|
const exwayApiKey = process.env.EXWAY_API_KEY || '';
|
||||||
try {
|
try {
|
||||||
const url = 'https://data.ex.co.kr/openapi/business/trafficFcst';
|
const url = 'https://data.ex.co.kr/openapi/business/trafficFcst';
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ export class RiskAlertService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2순위: 한국도로공사 API
|
// 2순위: 한국도로공사 API
|
||||||
const exwayApiKey = process.env.EXWAY_API_KEY || '7820214492';
|
const exwayApiKey = process.env.EXWAY_API_KEY || '';
|
||||||
try {
|
try {
|
||||||
const url = 'https://data.ex.co.kr/openapi/business/trafficFcst';
|
const url = 'https://data.ex.co.kr/openapi/business/trafficFcst';
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,7 @@
|
|||||||
process.env.NODE_ENV = "test";
|
process.env.NODE_ENV = "test";
|
||||||
// 실제 DB 연결을 위해 운영 데이터베이스 사용 (읽기 전용 테스트만 수행)
|
// 실제 DB 연결을 위해 운영 데이터베이스 사용 (읽기 전용 테스트만 수행)
|
||||||
process.env.DATABASE_URL =
|
process.env.DATABASE_URL =
|
||||||
process.env.TEST_DATABASE_URL ||
|
process.env.TEST_DATABASE_URL || "";
|
||||||
"postgresql://postgres:ph0909!!@39.117.244.52:11132/plm";
|
|
||||||
process.env.JWT_SECRET = "test-jwt-secret-key-for-testing-only";
|
process.env.JWT_SECRET = "test-jwt-secret-key-for-testing-only";
|
||||||
process.env.PORT = "3001";
|
process.env.PORT = "3001";
|
||||||
process.env.DEBUG = "true"; // 테스트 시 디버그 로그 활성화
|
process.env.DEBUG = "true"; // 테스트 시 디버그 로그 활성화
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
/**
|
|
||||||
* 리포트 관리 시스템 타입 정의
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 리포트 템플릿
|
|
||||||
export interface ReportTemplate {
|
export interface ReportTemplate {
|
||||||
template_id: string;
|
template_id: string;
|
||||||
template_name_kor: string;
|
template_name_kor: string;
|
||||||
@@ -21,12 +16,12 @@ export interface ReportTemplate {
|
|||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 마스터
|
|
||||||
export interface ReportMaster {
|
export interface ReportMaster {
|
||||||
report_id: string;
|
report_id: string;
|
||||||
report_name_kor: string;
|
report_name_kor: string;
|
||||||
report_name_eng: string | null;
|
report_name_eng: string | null;
|
||||||
template_id: string | null;
|
template_id: string | null;
|
||||||
|
template_name: string | null;
|
||||||
report_type: string;
|
report_type: string;
|
||||||
company_code: string | null;
|
company_code: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
@@ -37,7 +32,6 @@ export interface ReportMaster {
|
|||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 레이아웃
|
|
||||||
export interface ReportLayout {
|
export interface ReportLayout {
|
||||||
layout_id: string;
|
layout_id: string;
|
||||||
report_id: string;
|
report_id: string;
|
||||||
@@ -55,7 +49,6 @@ export interface ReportLayout {
|
|||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 쿼리
|
|
||||||
export interface ReportQuery {
|
export interface ReportQuery {
|
||||||
query_id: string;
|
query_id: string;
|
||||||
report_id: string;
|
report_id: string;
|
||||||
@@ -63,7 +56,7 @@ export interface ReportQuery {
|
|||||||
query_type: "MASTER" | "DETAIL";
|
query_type: "MASTER" | "DETAIL";
|
||||||
sql_query: string;
|
sql_query: string;
|
||||||
parameters: string[] | null;
|
parameters: string[] | null;
|
||||||
external_connection_id: number | null; // 외부 DB 연결 ID (NULL이면 내부 DB)
|
external_connection_id: number | null;
|
||||||
display_order: number;
|
display_order: number;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
@@ -71,34 +64,37 @@ export interface ReportQuery {
|
|||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 상세 (마스터 + 레이아웃 + 쿼리 + 연결된 메뉴)
|
|
||||||
export interface ReportDetail {
|
export interface ReportDetail {
|
||||||
report: ReportMaster;
|
report: ReportMaster;
|
||||||
layout: ReportLayout | null;
|
layout: ReportLayout | null;
|
||||||
queries: ReportQuery[];
|
queries: ReportQuery[];
|
||||||
menuObjids?: number[]; // 연결된 메뉴 ID 목록
|
menuObjids?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 목록 조회 파라미터
|
|
||||||
export interface GetReportsParams {
|
export interface GetReportsParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
searchText?: string;
|
searchText?: string;
|
||||||
|
searchField?: "report_name" | "created_by" | "report_type" | "updated_at" | "created_at";
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
reportType?: string;
|
reportType?: string;
|
||||||
useYn?: string;
|
useYn?: string;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: "ASC" | "DESC";
|
sortOrder?: "ASC" | "DESC";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 목록 응답
|
|
||||||
export interface GetReportsResponse {
|
export interface GetReportsResponse {
|
||||||
items: ReportMaster[];
|
items: ReportMaster[];
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
typeSummary: Array<{ type: string; count: number }>;
|
||||||
|
allTypes: string[];
|
||||||
|
recentActivity: Array<{ date: string; count: number }>;
|
||||||
|
recentTotal: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 생성 요청
|
|
||||||
export interface CreateReportRequest {
|
export interface CreateReportRequest {
|
||||||
reportNameKor: string;
|
reportNameKor: string;
|
||||||
reportNameEng?: string;
|
reportNameEng?: string;
|
||||||
@@ -108,7 +104,6 @@ export interface CreateReportRequest {
|
|||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트 수정 요청
|
|
||||||
export interface UpdateReportRequest {
|
export interface UpdateReportRequest {
|
||||||
reportNameKor?: string;
|
reportNameKor?: string;
|
||||||
reportNameEng?: string;
|
reportNameEng?: string;
|
||||||
@@ -117,23 +112,18 @@ export interface UpdateReportRequest {
|
|||||||
useYn?: string;
|
useYn?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 워터마크 설정
|
|
||||||
export interface WatermarkConfig {
|
export interface WatermarkConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
type: "text" | "image";
|
type: "text" | "image";
|
||||||
// 텍스트 워터마크
|
|
||||||
text?: string;
|
text?: string;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
fontColor?: string;
|
fontColor?: string;
|
||||||
// 이미지 워터마크
|
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
// 공통 설정
|
opacity: number;
|
||||||
opacity: number; // 0~1
|
|
||||||
style: "diagonal" | "center" | "tile";
|
style: "diagonal" | "center" | "tile";
|
||||||
rotation?: number; // 대각선일 때 각도 (기본 -45)
|
rotation?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 페이지 설정
|
|
||||||
export interface PageConfig {
|
export interface PageConfig {
|
||||||
page_id: string;
|
page_id: string;
|
||||||
page_name: string;
|
page_name: string;
|
||||||
@@ -147,30 +137,29 @@ export interface PageConfig {
|
|||||||
left: number;
|
left: number;
|
||||||
right: number;
|
right: number;
|
||||||
};
|
};
|
||||||
components: any[];
|
components: Record<string, unknown>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 레이아웃 설정
|
|
||||||
export interface ReportLayoutConfig {
|
export interface ReportLayoutConfig {
|
||||||
pages: PageConfig[];
|
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 {
|
export interface SaveLayoutRequest {
|
||||||
layoutConfig: ReportLayoutConfig;
|
layoutConfig: ReportLayoutConfig;
|
||||||
queries?: Array<{
|
queries?: SaveLayoutQueryItem[];
|
||||||
id: string;
|
menuObjids?: number[];
|
||||||
name: string;
|
|
||||||
type: "MASTER" | "DETAIL";
|
|
||||||
sqlQuery: string;
|
|
||||||
parameters: string[];
|
|
||||||
externalConnectionId?: number;
|
|
||||||
}>;
|
|
||||||
menuObjids?: number[]; // 연결할 메뉴 ID 목록
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리포트-메뉴 매핑
|
|
||||||
export interface ReportMenuMapping {
|
export interface ReportMenuMapping {
|
||||||
mapping_id: number;
|
mapping_id: number;
|
||||||
report_id: string;
|
report_id: string;
|
||||||
@@ -180,23 +169,20 @@ export interface ReportMenuMapping {
|
|||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 템플릿 목록 응답
|
|
||||||
export interface GetTemplatesResponse {
|
export interface GetTemplatesResponse {
|
||||||
system: ReportTemplate[];
|
system: ReportTemplate[];
|
||||||
custom: ReportTemplate[];
|
custom: ReportTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 템플릿 생성 요청
|
|
||||||
export interface CreateTemplateRequest {
|
export interface CreateTemplateRequest {
|
||||||
templateNameKor: string;
|
templateNameKor: string;
|
||||||
templateNameEng?: string;
|
templateNameEng?: string;
|
||||||
templateType: string;
|
templateType: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
layoutConfig?: any;
|
layoutConfig?: Record<string, unknown>;
|
||||||
defaultQueries?: any;
|
defaultQueries?: Array<Record<string, unknown>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컴포넌트 설정 (프론트엔드와 동기화)
|
|
||||||
export interface ComponentConfig {
|
export interface ComponentConfig {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -224,21 +210,16 @@ export interface ComponentConfig {
|
|||||||
conditional?: string;
|
conditional?: string;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
// 이미지 전용
|
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
objectFit?: "contain" | "cover" | "fill" | "none";
|
objectFit?: "contain" | "cover" | "fill" | "none";
|
||||||
// 구분선 전용
|
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
lineStyle?: "solid" | "dashed" | "dotted" | "double";
|
lineStyle?: "solid" | "dashed" | "dotted" | "double";
|
||||||
lineWidth?: number;
|
lineWidth?: number;
|
||||||
lineColor?: string;
|
lineColor?: string;
|
||||||
// 서명/도장 전용
|
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
labelText?: string;
|
labelText?: string;
|
||||||
labelPosition?: "top" | "left" | "bottom" | "right";
|
labelPosition?: "top" | "left" | "bottom" | "right";
|
||||||
showUnderline?: boolean;
|
|
||||||
personName?: string;
|
personName?: string;
|
||||||
// 테이블 전용
|
|
||||||
tableColumns?: Array<{
|
tableColumns?: Array<{
|
||||||
field: string;
|
field: string;
|
||||||
header: string;
|
header: string;
|
||||||
@@ -249,9 +230,7 @@ export interface ComponentConfig {
|
|||||||
headerTextColor?: string;
|
headerTextColor?: string;
|
||||||
showBorder?: boolean;
|
showBorder?: boolean;
|
||||||
rowHeight?: number;
|
rowHeight?: number;
|
||||||
// 페이지 번호 전용
|
|
||||||
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber";
|
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber";
|
||||||
// 카드 컴포넌트 전용
|
|
||||||
cardTitle?: string;
|
cardTitle?: string;
|
||||||
cardItems?: Array<{
|
cardItems?: Array<{
|
||||||
label: string;
|
label: string;
|
||||||
@@ -267,7 +246,6 @@ export interface ComponentConfig {
|
|||||||
titleColor?: string;
|
titleColor?: string;
|
||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
valueColor?: string;
|
valueColor?: string;
|
||||||
// 계산 컴포넌트 전용
|
|
||||||
calcItems?: Array<{
|
calcItems?: Array<{
|
||||||
label: string;
|
label: string;
|
||||||
value: number | string;
|
value: number | string;
|
||||||
@@ -280,7 +258,6 @@ export interface ComponentConfig {
|
|||||||
showCalcBorder?: boolean;
|
showCalcBorder?: boolean;
|
||||||
numberFormat?: "none" | "comma" | "currency";
|
numberFormat?: "none" | "comma" | "currency";
|
||||||
currencySuffix?: string;
|
currencySuffix?: string;
|
||||||
// 바코드 컴포넌트 전용
|
|
||||||
barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
|
barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
|
||||||
barcodeValue?: string;
|
barcodeValue?: string;
|
||||||
barcodeFieldName?: string;
|
barcodeFieldName?: string;
|
||||||
@@ -289,19 +266,118 @@ export interface ComponentConfig {
|
|||||||
barcodeBackground?: string;
|
barcodeBackground?: string;
|
||||||
barcodeMargin?: number;
|
barcodeMargin?: number;
|
||||||
qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H";
|
qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H";
|
||||||
// QR코드 다중 필드 (JSON 형식)
|
|
||||||
qrDataFields?: Array<{
|
qrDataFields?: Array<{
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
label: string;
|
label: string;
|
||||||
}>;
|
}>;
|
||||||
qrUseMultiField?: boolean;
|
qrUseMultiField?: boolean;
|
||||||
qrIncludeAllRows?: boolean;
|
qrIncludeAllRows?: boolean;
|
||||||
// 체크박스 컴포넌트 전용
|
checkboxChecked?: boolean;
|
||||||
checkboxChecked?: boolean; // 체크 상태 (고정값)
|
checkboxFieldName?: string;
|
||||||
checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값)
|
checkboxLabel?: string;
|
||||||
checkboxLabel?: string; // 체크박스 옆 레이블 텍스트
|
checkboxSize?: number;
|
||||||
checkboxSize?: number; // 체크박스 크기 (px)
|
checkboxColor?: string;
|
||||||
checkboxColor?: string; // 체크 색상
|
checkboxBorderColor?: string;
|
||||||
checkboxBorderColor?: string; // 테두리 색상
|
checkboxLabelPosition?: "left" | "right";
|
||||||
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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
```
|
||||||
@@ -12,10 +12,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- JWT_EXPIRES_IN=24h
|
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
|
||||||
- ENCRYPTION_KEY=ilshin-plm-encryption-key-2024-secure-32bytes
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
- CORS_ORIGIN=http://localhost:9771
|
- CORS_ORIGIN=http://localhost:9771
|
||||||
- CORS_CREDENTIALS=true
|
- CORS_CREDENTIALS=true
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
JWT_EXPIRES_IN: 24h
|
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h}
|
||||||
CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com
|
CORS_ORIGIN: ${CORS_ORIGIN:-https://v1.vexplor.com,https://api.vexplor.com}
|
||||||
CORS_CREDENTIALS: "true"
|
CORS_CREDENTIALS: "true"
|
||||||
LOG_LEVEL: info
|
LOG_LEVEL: info
|
||||||
ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
KMA_API_KEY: ogdXr2e9T4iHV69nvV-IwA
|
KMA_API_KEY: ${KMA_API_KEY}
|
||||||
ITS_API_KEY: d6b9befec3114d648284674b8fddcc32
|
ITS_API_KEY: ${ITS_API_KEY}
|
||||||
EXPRESSWAY_API_KEY: ${EXPRESSWAY_API_KEY:-}
|
EXPRESSWAY_API_KEY: ${EXPRESSWAY_API_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- backend_uploads:/app/uploads
|
- backend_uploads:/app/uploads
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- JWT_EXPIRES_IN=24h
|
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
|
||||||
- CORS_ORIGIN=http://localhost:9771
|
- CORS_ORIGIN=http://localhost:9771
|
||||||
- CORS_CREDENTIALS=true
|
- CORS_CREDENTIALS=true
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
- ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
- KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
- KMA_API_KEY=${KMA_API_KEY}
|
||||||
- ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
- ITS_API_KEY=${ITS_API_KEY}
|
||||||
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
|
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
|
||||||
volumes:
|
volumes:
|
||||||
- ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영
|
- ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영
|
||||||
|
|||||||
@@ -13,15 +13,15 @@ services:
|
|||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
- HOST=0.0.0.0 # 모든 인터페이스에서 바인딩
|
- HOST=0.0.0.0 # 모든 인터페이스에서 바인딩
|
||||||
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- JWT_EXPIRES_IN=24h
|
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-24h}
|
||||||
- CORS_ORIGIN=http://192.168.0.70:5555,http://39.117.244.52:5555,http://localhost:9771
|
- CORS_ORIGIN=${CORS_ORIGIN:-http://192.168.0.70:5555,http://39.117.244.52:5555,http://localhost:9771}
|
||||||
- CORS_CREDENTIALS=true
|
- CORS_CREDENTIALS=true
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=info
|
||||||
- ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
- KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
- KMA_API_KEY=${KMA_API_KEY}
|
||||||
- ITS_API_KEY=d6b9befec3114d648284674b8fddcc32
|
- ITS_API_KEY=${ITS_API_KEY}
|
||||||
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
|
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ screen_layouts (V1) screen_layouts_v2 (V2)
|
|||||||
docker exec pms-backend-mac node -e '
|
docker exec pms-backend-mac node -e '
|
||||||
const { Pool } = require("pg");
|
const { Pool } = require("pg");
|
||||||
const pool = new Pool({
|
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
|
ssl: false
|
||||||
});
|
});
|
||||||
// 쿼리 실행
|
// 쿼리 실행
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ CREATE TABLE backup_20260323_screen_group_screens AS
|
|||||||
### STEP 1: 누락 테이블 생성 (배포 DB에서 psql 실행)
|
### STEP 1: 누락 테이블 생성 (배포 DB에서 psql 실행)
|
||||||
|
|
||||||
```bash
|
```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
|
#### 1-1. work_order_process
|
||||||
@@ -366,7 +366,7 @@ COPY (
|
|||||||
) TO STDOUT WITH CSV HEADER" > /tmp/ttc_export.csv
|
) TO STDOUT WITH CSV HEADER" > /tmp/ttc_export.csv
|
||||||
|
|
||||||
# 배포 DB에 삽입 (충돌 시 무시)
|
# 배포 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
|
COPY table_type_columns FROM STDIN WITH CSV HEADER
|
||||||
ON CONFLICT DO NOTHING" < /tmp/ttc_export.csv
|
ON CONFLICT DO NOTHING" < /tmp/ttc_export.csv
|
||||||
```
|
```
|
||||||
@@ -386,7 +386,7 @@ COPY (
|
|||||||
) TO STDOUT WITH CSV HEADER" > /tmp/screen_def.csv
|
) 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
|
COPY screen_definitions FROM STDIN WITH CSV HEADER
|
||||||
ON CONFLICT DO NOTHING" < /tmp/screen_def.csv
|
ON CONFLICT DO NOTHING" < /tmp/screen_def.csv
|
||||||
|
|
||||||
@@ -400,7 +400,7 @@ COPY (
|
|||||||
) TO STDOUT WITH CSV HEADER" > /tmp/screen_layouts.csv
|
) 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)
|
COPY screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
|
||||||
FROM STDIN WITH CSV HEADER
|
FROM STDIN WITH CSV HEADER
|
||||||
ON CONFLICT (screen_id, company_code) DO NOTHING" < /tmp/screen_layouts.csv
|
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
|
) 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
|
COPY screen_groups FROM STDIN WITH CSV HEADER
|
||||||
ON CONFLICT DO NOTHING" < /tmp/screen_groups.csv
|
ON CONFLICT DO NOTHING" < /tmp/screen_groups.csv
|
||||||
|
|
||||||
@@ -427,7 +427,7 @@ COPY (
|
|||||||
) TO STDOUT WITH CSV HEADER" > /tmp/screen_group_screens.csv
|
) 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
|
COPY screen_group_screens FROM STDIN WITH CSV HEADER
|
||||||
ON CONFLICT DO NOTHING" < /tmp/screen_group_screens.csv
|
ON CONFLICT DO NOTHING" < /tmp/screen_group_screens.csv
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
현재 `.env`에 설정된 키:
|
현재 `.env`에 설정된 키:
|
||||||
```bash
|
```bash
|
||||||
KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
KMA_API_KEY=${KMA_API_KEY}
|
||||||
```
|
```
|
||||||
|
|
||||||
**사용 API:**
|
**사용 API:**
|
||||||
@@ -105,7 +105,7 @@ nano .env
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 기상청 API Hub 키 (기존 - 예특보 활용신청 완료 시 사용)
|
# 기상청 API Hub 키 (기존 - 예특보 활용신청 완료 시 사용)
|
||||||
KMA_API_KEY=ogdXr2e9T4iHV69nvV-IwA
|
KMA_API_KEY=${KMA_API_KEY}
|
||||||
|
|
||||||
# 국토교통부 도로교통 API 키 (활용신청 완료 시 추가)
|
# 국토교통부 도로교통 API 키 (활용신청 완료 시 추가)
|
||||||
MOLIT_TRAFFIC_API_KEY=여기에_발급받은_교통사고_API_인증키_붙여넣기
|
MOLIT_TRAFFIC_API_KEY=여기에_발급받은_교통사고_API_인증키_붙여넣기
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ uploads/
|
|||||||
|
|
||||||
### 필수 환경변수
|
### 필수 환경변수
|
||||||
```bash
|
```bash
|
||||||
ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 필수 디렉토리
|
### 필수 디렉토리
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ MailDesigner 통합 및 템플릿 저장/불러오기
|
|||||||
### 필수 환경변수
|
### 필수 환경변수
|
||||||
```bash
|
```bash
|
||||||
# docker/dev/docker-compose.backend.mac.yml
|
# docker/dev/docker-compose.backend.mac.yml
|
||||||
ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 저장 디렉토리 생성
|
### 저장 디렉토리 생성
|
||||||
|
|||||||
@@ -457,6 +457,8 @@ export default function CustomerManagementPage() {
|
|||||||
try {
|
try {
|
||||||
const filters: any[] = [];
|
const filters: any[] = [];
|
||||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
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`, {
|
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||||
page: 1, size: 50,
|
page: 1, size: 50,
|
||||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ export default function SalesOrderPage() {
|
|||||||
const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || "";
|
const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || "";
|
||||||
const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || "";
|
const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || "";
|
||||||
const defaultPriceMode = categoryOptions["price_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([]);
|
setDetailRows([]);
|
||||||
setDeliveryOptions([]);
|
setDeliveryOptions([]);
|
||||||
setIsEditMode(false);
|
setIsEditMode(false);
|
||||||
@@ -474,16 +474,9 @@ export default function SalesOrderPage() {
|
|||||||
if (itemSearchKeyword) {
|
if (itemSearchKeyword) {
|
||||||
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||||
}
|
}
|
||||||
|
// division 필터는 다중값(쉼표 구분)이므로 contains로 부분 매칭
|
||||||
if (itemSearchDivision !== "all") {
|
if (itemSearchDivision !== "all") {
|
||||||
filters.push({ columnName: "division", operator: "equals", value: itemSearchDivision });
|
filters.push({ columnName: "division", operator: "contains", 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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||||
page: p, size: s,
|
page: p, size: s,
|
||||||
|
|||||||
@@ -166,8 +166,8 @@ export default function SalesItemPage() {
|
|||||||
try {
|
try {
|
||||||
const filters: { columnName: string; operator: string; value: any }[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
const filters: { columnName: string; operator: string; value: any }[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||||
|
|
||||||
// 판매품목 division 필터 (다중값 컬럼이므로 contains로 매칭)
|
// 판매품목/영업관리 division 필터 (다중값 컬럼이므로 contains로 매칭)
|
||||||
filters.push({ columnName: "division", operator: "contains", value: "CAT_DIV_SALES" });
|
filters.push({ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" });
|
||||||
|
|
||||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||||
page: 1, size: 500,
|
page: 1, size: 500,
|
||||||
|
|||||||
@@ -105,9 +105,11 @@ export default function EquipmentInfoPage() {
|
|||||||
|
|
||||||
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
|
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
|
||||||
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||||
|
const [inspectionContinuous, setInspectionContinuous] = useState(false);
|
||||||
|
|
||||||
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||||
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
|
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
|
||||||
|
const [consumableContinuous, setConsumableContinuous] = useState(false);
|
||||||
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||||
|
|
||||||
// 점검항목 복사
|
// 점검항목 복사
|
||||||
@@ -294,7 +296,13 @@ export default function EquipmentInfoPage() {
|
|||||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||||
...inspectionForm, equipment_code: selectedEquip?.equipment_code,
|
...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); }
|
} 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`, {
|
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
|
||||||
...consumableForm, equipment_code: selectedEquip?.equipment_code,
|
...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); }
|
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -609,8 +623,16 @@ export default function EquipmentInfoPage() {
|
|||||||
<div className="space-y-1.5"><Label className="text-sm">점검내용</Label>
|
<div className="space-y-1.5"><Label className="text-sm">점검내용</Label>
|
||||||
<Input value={inspectionForm.inspection_content || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" /></div>
|
<Input value={inspectionForm.inspection_content || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" /></div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter><Button variant="outline" onClick={() => setInspectionModalOpen(false)}>취소</Button>
|
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
||||||
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button></DialogFooter>
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={inspectionContinuous} onCheckedChange={(c) => setInspectionContinuous(!!c)} />
|
||||||
|
저장 후 계속 입력
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setInspectionModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
@@ -659,8 +681,16 @@ export default function EquipmentInfoPage() {
|
|||||||
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
|
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
|
||||||
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
|
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter><Button variant="outline" onClick={() => setConsumableModalOpen(false)}>취소</Button>
|
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
||||||
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button></DialogFooter>
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={consumableContinuous} onCheckedChange={(c) => setConsumableContinuous(!!c)} />
|
||||||
|
저장 후 계속 입력
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setConsumableModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
ChevronsRight,
|
ChevronsRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
import {
|
import {
|
||||||
getReceivingList,
|
getReceivingList,
|
||||||
createReceiving,
|
createReceiving,
|
||||||
@@ -140,13 +141,23 @@ export default function ReceivingPage() {
|
|||||||
const [sourcePageSize, setSourcePageSize] = useState(20);
|
const [sourcePageSize, setSourcePageSize] = useState(20);
|
||||||
const [sourceTotalCount, setSourceTotalCount] = useState(0);
|
const [sourceTotalCount, setSourceTotalCount] = useState(0);
|
||||||
|
|
||||||
// 날짜 초기화
|
// 구매관리 division 코드 (라벨 기준 조회)
|
||||||
|
const [purchaseDivisionCode, setPurchaseDivisionCode] = useState<string>("");
|
||||||
|
|
||||||
|
// 날짜 초기화 + 구매관리 division 코드 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const threeMonthsAgo = new Date(today);
|
const threeMonthsAgo = new Date(today);
|
||||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||||
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
|
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
|
||||||
setSearchDateTo(today.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);
|
setSourceTotalCount(res.totalCount || 0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const res = await getItemSources(params);
|
const res = await getItemSources({ ...params, division: purchaseDivisionCode || undefined });
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setItems(res.data);
|
setItems(res.data);
|
||||||
setSourceTotalCount(res.totalCount || 0);
|
setSourceTotalCount(res.totalCount || 0);
|
||||||
|
|||||||
+675
-207
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { Plus, Trash2, RotateCcw, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown, ClipboardCheck } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -39,6 +40,7 @@ interface EmployeeOption { user_id: string; user_name: string; dept_name: string
|
|||||||
interface SelectedItem {
|
interface SelectedItem {
|
||||||
itemCode: string; itemName: string; spec: string; qty: number; remark: string;
|
itemCode: string; itemName: string; spec: string; qty: number; remark: string;
|
||||||
sourceType: SourceType; sourceTable: string; sourceId: string | number;
|
sourceType: SourceType; sourceTable: string; sourceId: string | number;
|
||||||
|
routing?: string; routingOptions?: RoutingVersionData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WorkInstructionPage() {
|
export default function WorkInstructionPage() {
|
||||||
@@ -206,14 +208,17 @@ export default function WorkInstructionPage() {
|
|||||||
setConfirmRouting(""); setConfirmRoutingOptions([]);
|
setConfirmRouting(""); setConfirmRoutingOptions([]);
|
||||||
previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)"));
|
previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)"));
|
||||||
|
|
||||||
// 첫 번째 품목의 라우팅 로드
|
// 품목별 라우팅 옵션 로드
|
||||||
const firstItem = items.length > 0 ? items[0] : null;
|
const finalItems = regMergeSameItem ? Array.from(new Map(items.map(i => [i.itemCode, i])).values()) : items;
|
||||||
if (firstItem) {
|
const uniqueItemCodes = [...new Set(finalItems.map(i => i.itemCode).filter(Boolean))];
|
||||||
getRoutingVersions("__new__", firstItem.itemCode).then(r => {
|
for (const ic of uniqueItemCodes) {
|
||||||
|
getRoutingVersions("__new__", ic).then(r => {
|
||||||
if (r.success && r.data) {
|
if (r.success && r.data) {
|
||||||
setConfirmRoutingOptions(r.data);
|
setConfirmItems(prev => prev.map(it => {
|
||||||
const defaultRouting = r.data.find(rv => rv.is_default);
|
if (it.itemCode !== ic) return it;
|
||||||
if (defaultRouting) setConfirmRouting(defaultRouting.id);
|
const defaultRv = r.data.find(rv => rv.is_default);
|
||||||
|
return { ...it, routingOptions: r.data, routing: defaultRv?.id || "" };
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -242,7 +247,7 @@ export default function WorkInstructionPage() {
|
|||||||
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
|
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
|
||||||
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
|
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
|
||||||
routing: confirmRouting || null,
|
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);
|
const r = await saveWorkInstruction(payload);
|
||||||
if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); }
|
if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); }
|
||||||
@@ -258,21 +263,35 @@ export default function WorkInstructionPage() {
|
|||||||
setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || "");
|
setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || "");
|
||||||
setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || "");
|
setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || "");
|
||||||
setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || "");
|
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 || "",
|
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 || "",
|
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,
|
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 || "",
|
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("");
|
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
|
||||||
setEditRouting(order.routing_version_id || "");
|
setEditRouting(order.routing_version_id || "");
|
||||||
setEditRoutingOptions([]);
|
setEditRoutingOptions([]);
|
||||||
|
|
||||||
// 라우팅 옵션 로드
|
// 품목별 라우팅 옵션 로드
|
||||||
const itemCode = order.item_number || order.part_code || "";
|
const uniqueItemCodes = [...new Set(items.map(i => i.itemCode).filter(Boolean))];
|
||||||
if (itemCode) {
|
for (const ic of uniqueItemCodes) {
|
||||||
getRoutingVersions(wiNo, itemCode).then(r => {
|
getRoutingVersions(wiNo, ic).then(r => {
|
||||||
if (r.success && r.data) setEditRoutingOptions(r.data);
|
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(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +315,7 @@ export default function WorkInstructionPage() {
|
|||||||
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
|
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
|
||||||
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
|
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
|
||||||
routing: editRouting || null,
|
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);
|
const r = await saveWorkInstruction(payload);
|
||||||
if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); }
|
if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); }
|
||||||
@@ -600,13 +619,20 @@ export default function WorkInstructionPage() {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* ── 2단계: 확인 모달 ── */}
|
{/* ── 2단계: 확인 모달 ── */}
|
||||||
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
|
<FullscreenDialog
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[1000px] max-h-[90vh] flex flex-col p-0 gap-0">
|
open={isConfirmModalOpen}
|
||||||
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
onOpenChange={setIsConfirmModalOpen}
|
||||||
<DialogTitle className="text-base flex items-center gap-2"><CheckCircle2 className="w-4 h-4" /> 작업지시 적용 확인</DialogTitle>
|
title="작업지시 적용 확인"
|
||||||
<DialogDescription className="text-xs">기본 정보를 입력하고 "최종 적용" 버튼을 눌러주세요.</DialogDescription>
|
description="기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요."
|
||||||
</DialogHeader>
|
footer={
|
||||||
<div className="flex-1 overflow-auto p-6 space-y-5">
|
<>
|
||||||
|
<Button variant="outline" onClick={() => { setIsConfirmModalOpen(false); setIsRegModalOpen(true); }}><ChevronLeft className="w-4 h-4 mr-1" /> 이전</Button>
|
||||||
|
<Button variant="outline" onClick={() => setIsConfirmModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={finalizeRegistration} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <CheckCircle2 className="w-4 h-4 mr-1.5" />} 최종 적용</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
<div className="bg-muted/30 border rounded-lg p-5">
|
<div className="bg-muted/30 border rounded-lg p-5">
|
||||||
<h4 className="text-sm font-semibold mb-4">작업지시 기본 정보</h4>
|
<h4 className="text-sm font-semibold mb-4">작업지시 기본 정보</h4>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
@@ -625,27 +651,24 @@ export default function WorkInstructionPage() {
|
|||||||
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
||||||
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
|
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5"><Label className="text-xs">라우팅</Label>
|
<div className="space-y-1.5"><Label className="text-xs">비고</Label><Input className="h-9" placeholder="비고" /></div>
|
||||||
<Select value={nv(confirmRouting)} onValueChange={v => setConfirmRouting(fromNv(v))}>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">선택 안 함</SelectItem>
|
|
||||||
{confirmRoutingOptions.map(rv => (
|
|
||||||
<SelectItem key={rv.id} value={rv.id}>
|
|
||||||
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg p-5">
|
<div className="border rounded-lg p-5">
|
||||||
<h4 className="text-sm font-semibold mb-3">품목 목록</h4>
|
<h4 className="text-sm font-semibold mb-3">품목 목록</h4>
|
||||||
<div className="max-h-[300px] overflow-auto">
|
<div className="overflow-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 bg-background z-10">
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[100px]">수량</TableHead><TableHead>비고</TableHead><TableHead className="w-[60px]" /></TableRow>
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px]">순번</TableHead>
|
||||||
|
<TableHead className="w-[110px]">품목코드</TableHead>
|
||||||
|
<TableHead>품목명</TableHead>
|
||||||
|
<TableHead className="w-[80px]">규격</TableHead>
|
||||||
|
<TableHead className="w-[90px]">수량</TableHead>
|
||||||
|
<TableHead className="w-[180px]">라우팅</TableHead>
|
||||||
|
<TableHead>비고</TableHead>
|
||||||
|
<TableHead className="w-[40px]" />
|
||||||
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{confirmItems.map((item, idx) => (
|
{confirmItems.map((item, idx) => (
|
||||||
@@ -655,6 +678,25 @@ export default function WorkInstructionPage() {
|
|||||||
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
|
<TableCell className="text-sm">{item.itemName || item.itemCode}</TableCell>
|
||||||
<TableCell className="text-xs">{item.spec || "-"}</TableCell>
|
<TableCell className="text-xs">{item.spec || "-"}</TableCell>
|
||||||
<TableCell><Input type="number" className="h-7 text-xs w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
<TableCell><Input type="number" className="h-7 text-xs w-20" value={item.qty} onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select
|
||||||
|
value={nv(item.routing || "")}
|
||||||
|
onValueChange={v => {
|
||||||
|
const val = fromNv(v);
|
||||||
|
setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, routing: val } : it));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="라우팅" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">선택 안 함</SelectItem>
|
||||||
|
{(item.routingOptions || []).map((rv: RoutingVersionData) => (
|
||||||
|
<SelectItem key={rv.id} value={rv.id}>
|
||||||
|
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||||
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setConfirmItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -664,22 +706,22 @@ export default function WorkInstructionPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
</FullscreenDialog>
|
||||||
<Button variant="outline" onClick={() => { setIsConfirmModalOpen(false); setIsRegModalOpen(true); }}><ChevronLeft className="w-4 h-4 mr-1" /> 이전</Button>
|
|
||||||
<Button variant="outline" onClick={() => setIsConfirmModalOpen(false)}>취소</Button>
|
|
||||||
<Button onClick={finalizeRegistration} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <CheckCircle2 className="w-4 h-4 mr-1.5" />} 최종 적용</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* ── 수정 모달 ── */}
|
{/* ── 수정 모달 ── */}
|
||||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
<FullscreenDialog
|
||||||
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[90vh] flex flex-col p-0 gap-0">
|
open={isEditModalOpen}
|
||||||
<DialogHeader className="px-6 py-4 border-b shrink-0">
|
onOpenChange={(v) => { if (!v && wsModalOpen) return; setIsEditModalOpen(v); }}
|
||||||
<DialogTitle className="text-base flex items-center gap-2"><Wrench className="w-4 h-4" /> 작업지시 관리 - {editOrder?.work_instruction_no}</DialogTitle>
|
title={`작업지시 관리 - ${editOrder?.work_instruction_no || ""}`}
|
||||||
<DialogDescription className="text-xs">품목을 추가/삭제하고 정보를 수정하세요.</DialogDescription>
|
description="품목을 추가/삭제하고 정보를 수정하세요."
|
||||||
</DialogHeader>
|
footer={
|
||||||
<div className="flex-1 overflow-auto p-6 space-y-5">
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={saveEdit} disabled={editSaving}>{editSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
<div className="bg-muted/30 border rounded-lg p-5">
|
<div className="bg-muted/30 border rounded-lg p-5">
|
||||||
<h4 className="text-sm font-semibold mb-4">기본 정보</h4>
|
<h4 className="text-sm font-semibold mb-4">기본 정보</h4>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
@@ -691,64 +733,81 @@ export default function WorkInstructionPage() {
|
|||||||
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
<div className="space-y-1.5"><Label className="text-xs">작업자</Label>
|
||||||
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
|
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5"><Label className="text-xs">라우팅</Label>
|
|
||||||
<Select value={nv(editRouting)} onValueChange={v => setEditRouting(fromNv(v))}>
|
|
||||||
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">선택 안 함</SelectItem>
|
|
||||||
{editRoutingOptions.map(rv => (
|
|
||||||
<SelectItem key={rv.id} value={rv.id}>
|
|
||||||
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5"><Label className="text-xs">공정작업기준</Label>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-9 w-full text-xs"
|
|
||||||
disabled={!editRouting}
|
|
||||||
onClick={() => {
|
|
||||||
if (!editOrder || !editRouting) return;
|
|
||||||
const rv = editRoutingOptions.find(r => r.id === editRouting);
|
|
||||||
openWorkStandardModal(
|
|
||||||
editOrder.work_instruction_no,
|
|
||||||
editRouting,
|
|
||||||
rv?.version_name || "",
|
|
||||||
editOrder.item_name || editOrder.item_number || "",
|
|
||||||
editOrder.item_number || ""
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ClipboardCheck className="w-3.5 h-3.5 mr-1.5" /> 작업기준 수정
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5 col-span-2"><Label className="text-xs">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고" /></div>
|
<div className="space-y-1.5 col-span-2"><Label className="text-xs">비고</Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고" /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 품목 테이블 */}
|
{/* 품목 테이블 — 라우팅/공정작업기준을 품목별로 표시 */}
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/20 border-b">
|
<div className="flex items-center justify-between p-3 bg-muted/20 border-b">
|
||||||
<span className="text-sm font-semibold">작업지시 항목</span>
|
<span className="text-sm font-semibold">작업지시 항목</span>
|
||||||
<span className="text-xs text-muted-foreground">{editItems.length}건</span>
|
<span className="text-xs text-muted-foreground">{editItems.length}건</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[280px] overflow-auto">
|
<div className="overflow-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 bg-background z-10">
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[100px] text-right">수량</TableHead><TableHead>비고</TableHead><TableHead className="w-[60px]" /></TableRow>
|
<TableRow>
|
||||||
|
<TableHead className="w-[50px]">순번</TableHead>
|
||||||
|
<TableHead className="w-[110px]">품목코드</TableHead>
|
||||||
|
<TableHead>품목명</TableHead>
|
||||||
|
<TableHead className="w-[80px]">규격</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-right">수량</TableHead>
|
||||||
|
<TableHead className="w-[180px]">라우팅</TableHead>
|
||||||
|
<TableHead className="w-[100px]">공정작업기준</TableHead>
|
||||||
|
<TableHead>비고</TableHead>
|
||||||
|
<TableHead className="w-[40px]" />
|
||||||
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{editItems.length === 0 ? (
|
{editItems.length === 0 ? (
|
||||||
<TableRow><TableCell colSpan={7} className="text-center py-8 text-muted-foreground text-sm">품목이 없습니다</TableCell></TableRow>
|
<TableRow><TableCell colSpan={9} className="text-center py-8 text-muted-foreground text-sm">품목이 없습니다</TableCell></TableRow>
|
||||||
) : editItems.map((item, idx) => (
|
) : editItems.map((item, idx) => (
|
||||||
<TableRow key={idx}>
|
<TableRow key={idx}>
|
||||||
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
||||||
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
||||||
<TableCell className="text-xs max-w-[180px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
<TableCell className="text-xs max-w-[150px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
||||||
<TableCell className="text-xs max-w-[100px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
|
<TableCell className="text-xs truncate" title={item.spec}>{item.spec || "-"}</TableCell>
|
||||||
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Select
|
||||||
|
value={nv(item.routing || "")}
|
||||||
|
onValueChange={v => {
|
||||||
|
const val = fromNv(v);
|
||||||
|
setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, routing: val } : it));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="라우팅" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">선택 안 함</SelectItem>
|
||||||
|
{(item.routingOptions || []).map((rv: RoutingVersionData) => (
|
||||||
|
<SelectItem key={rv.id} value={rv.id}>
|
||||||
|
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}공정
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={!item.routing}
|
||||||
|
onClick={() => {
|
||||||
|
if (!editOrder || !item.routing) return;
|
||||||
|
const rv = (item.routingOptions || []).find((r: RoutingVersionData) => r.id === item.routing);
|
||||||
|
openWorkStandardModal(
|
||||||
|
editOrder.work_instruction_no,
|
||||||
|
item.routing,
|
||||||
|
rv?.version_name || "",
|
||||||
|
item.itemName || item.itemCode || "",
|
||||||
|
item.itemCode || ""
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ClipboardCheck className="w-3 h-3 mr-1" /> 수정
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||||
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -764,12 +823,7 @@ export default function WorkInstructionPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="px-6 py-3 border-t shrink-0">
|
</FullscreenDialog>
|
||||||
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}>취소</Button>
|
|
||||||
<Button onClick={saveEdit} disabled={editSaving}>{editSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* 공정작업기준 수정 모달 */}
|
{/* 공정작업기준 수정 모달 */}
|
||||||
<WorkStandardEditModal
|
<WorkStandardEditModal
|
||||||
|
|||||||
@@ -95,8 +95,10 @@ export default function CustomerManagementPage() {
|
|||||||
// 우측: 품목 단가
|
// 우측: 품목 단가
|
||||||
const [priceItems, setPriceItems] = useState<any[]>([]);
|
const [priceItems, setPriceItems] = useState<any[]>([]);
|
||||||
const [priceLoading, setPriceLoading] = useState(false);
|
const [priceLoading, setPriceLoading] = useState(false);
|
||||||
|
const [priceCheckedIds, setPriceCheckedIds] = useState<string[]>([]);
|
||||||
// 우측: 납품처
|
// 우측: 납품처
|
||||||
const [deliveryItems, setDeliveryItems] = useState<any[]>([]);
|
const [deliveryItems, setDeliveryItems] = useState<any[]>([]);
|
||||||
|
const [deliveryCheckedIds, setDeliveryCheckedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
// 품목 편집 데이터 (더블클릭 시 — 상세 입력 모달 재활용)
|
// 품목 편집 데이터 (더블클릭 시 — 상세 입력 모달 재활용)
|
||||||
const [editItemData, setEditItemData] = useState<any>(null);
|
const [editItemData, setEditItemData] = useState<any>(null);
|
||||||
@@ -254,7 +256,8 @@ export default function CustomerManagementPage() {
|
|||||||
const selectedCustomer = customers.find((c) => c.id === selectedCustomerId);
|
const selectedCustomer = customers.find((c) => c.id === selectedCustomerId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCustomer?.customer_code) { setPriceItems([]); return; }
|
if (!selectedCustomer?.customer_code) { setPriceItems([]); setPriceCheckedIds([]); return; }
|
||||||
|
setPriceCheckedIds([]);
|
||||||
const fetchItems = async () => {
|
const fetchItems = async () => {
|
||||||
setPriceLoading(true);
|
setPriceLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -345,7 +348,8 @@ export default function CustomerManagementPage() {
|
|||||||
|
|
||||||
// 납품처 조회
|
// 납품처 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCustomer?.customer_code) { setDeliveryItems([]); return; }
|
if (!selectedCustomer?.customer_code) { setDeliveryItems([]); setDeliveryCheckedIds([]); return; }
|
||||||
|
setDeliveryCheckedIds([]);
|
||||||
const fetchDelivery = async () => {
|
const fetchDelivery = async () => {
|
||||||
setDeliveryLoading(true);
|
setDeliveryLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -457,6 +461,8 @@ export default function CustomerManagementPage() {
|
|||||||
try {
|
try {
|
||||||
const filters: any[] = [];
|
const filters: any[] = [];
|
||||||
if (itemSearchKeyword) filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
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`, {
|
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||||
page: 1, size: 50,
|
page: 1, size: 50,
|
||||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
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 () => {
|
const handleExcelDownload = async () => {
|
||||||
if (customers.length === 0) return;
|
if (customers.length === 0) return;
|
||||||
@@ -965,16 +1034,28 @@ export default function CustomerManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{rightTab === "items" && (
|
{rightTab === "items" && (
|
||||||
<Button variant="outline" size="sm" disabled={!selectedCustomerId}
|
<>
|
||||||
onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
|
<Button variant="outline" size="sm" disabled={!selectedCustomerId}
|
||||||
<Plus className="w-3.5 h-3.5 mr-1" /> 품목 추가
|
onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
|
||||||
</Button>
|
<Plus className="w-3.5 h-3.5 mr-1" /> 품목 추가
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" disabled={priceCheckedIds.length === 0}
|
||||||
|
onClick={handlePriceItemDelete}>
|
||||||
|
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{rightTab === "delivery" && (
|
{rightTab === "delivery" && (
|
||||||
<Button variant="outline" size="sm" disabled={!selectedCustomerId}
|
<>
|
||||||
onClick={() => { setDeliveryForm({}); setDeliveryModalOpen(true); }}>
|
<Button variant="outline" size="sm" disabled={!selectedCustomerId}
|
||||||
<Plus className="w-3.5 h-3.5 mr-1" /> 납품처 등록
|
onClick={() => { setDeliveryForm({}); setDeliveryModalOpen(true); }}>
|
||||||
</Button>
|
<Plus className="w-3.5 h-3.5 mr-1" /> 납품처 등록
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" disabled={deliveryCheckedIds.length === 0}
|
||||||
|
onClick={handleDeliveryDelete}>
|
||||||
|
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -991,6 +1072,9 @@ export default function CustomerManagementPage() {
|
|||||||
data={priceItems}
|
data={priceItems}
|
||||||
loading={priceLoading}
|
loading={priceLoading}
|
||||||
showRowNumber={false}
|
showRowNumber={false}
|
||||||
|
showCheckbox
|
||||||
|
checkedIds={priceCheckedIds}
|
||||||
|
onCheckedChange={setPriceCheckedIds}
|
||||||
tableName={MAPPING_TABLE}
|
tableName={MAPPING_TABLE}
|
||||||
emptyMessage="등록된 품목이 없습니다"
|
emptyMessage="등록된 품목이 없습니다"
|
||||||
onRowDoubleClick={(row) => openEditItem(row)}
|
onRowDoubleClick={(row) => openEditItem(row)}
|
||||||
@@ -1010,6 +1094,9 @@ export default function CustomerManagementPage() {
|
|||||||
data={deliveryItems}
|
data={deliveryItems}
|
||||||
loading={deliveryLoading}
|
loading={deliveryLoading}
|
||||||
showRowNumber={false}
|
showRowNumber={false}
|
||||||
|
showCheckbox
|
||||||
|
checkedIds={deliveryCheckedIds}
|
||||||
|
onCheckedChange={setDeliveryCheckedIds}
|
||||||
tableName={DELIVERY_TABLE}
|
tableName={DELIVERY_TABLE}
|
||||||
emptyMessage="등록된 납품처가 없습니다"
|
emptyMessage="등록된 납품처가 없습니다"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -209,6 +209,12 @@ export default function SalesOrderPage() {
|
|||||||
} catch { /* skip */ }
|
} catch { /* skip */ }
|
||||||
}
|
}
|
||||||
setCategoryOptions(optMap);
|
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();
|
loadCategories();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -326,7 +332,7 @@ export default function SalesOrderPage() {
|
|||||||
const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || "";
|
const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || "";
|
||||||
const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || "";
|
const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || "";
|
||||||
const defaultPriceMode = categoryOptions["price_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([]);
|
setDetailRows([]);
|
||||||
setDeliveryOptions([]);
|
setDeliveryOptions([]);
|
||||||
setIsEditMode(false);
|
setIsEditMode(false);
|
||||||
@@ -474,16 +480,9 @@ export default function SalesOrderPage() {
|
|||||||
if (itemSearchKeyword) {
|
if (itemSearchKeyword) {
|
||||||
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||||
}
|
}
|
||||||
|
// division 필터는 다중값(쉼표 구분)이므로 contains로 부분 매칭
|
||||||
if (itemSearchDivision !== "all") {
|
if (itemSearchDivision !== "all") {
|
||||||
filters.push({ columnName: "division", operator: "equals", value: itemSearchDivision });
|
filters.push({ columnName: "division", operator: "contains", 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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||||
page: p, size: s,
|
page: p, size: s,
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default function SalesItemPage() {
|
|||||||
// 우측: 거래처
|
// 우측: 거래처
|
||||||
const [customerItems, setCustomerItems] = useState<any[]>([]);
|
const [customerItems, setCustomerItems] = useState<any[]>([]);
|
||||||
const [customerLoading, setCustomerLoading] = useState(false);
|
const [customerLoading, setCustomerLoading] = useState(false);
|
||||||
|
const [customerCheckedIds, setCustomerCheckedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
// 카테고리
|
// 카테고리
|
||||||
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string; isDefault?: boolean }[]>>({});
|
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string; isDefault?: boolean }[]>>({});
|
||||||
@@ -166,8 +167,8 @@ export default function SalesItemPage() {
|
|||||||
try {
|
try {
|
||||||
const filters: { columnName: string; operator: string; value: any }[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
const filters: { columnName: string; operator: string; value: any }[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||||
|
|
||||||
// 판매품목 division 필터 (다중값 컬럼이므로 contains로 매칭)
|
// 판매품목/영업관리 division 필터 (다중값 컬럼이므로 contains로 매칭)
|
||||||
filters.push({ columnName: "division", operator: "contains", value: "CAT_DIV_SALES" });
|
filters.push({ columnName: "division", operator: "contains", value: "CAT_ML8ZFVEL_1TOR" });
|
||||||
|
|
||||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||||
page: 1, size: 500,
|
page: 1, size: 500,
|
||||||
@@ -200,12 +201,13 @@ export default function SalesItemPage() {
|
|||||||
|
|
||||||
// 우측: 거래처 목록 조회
|
// 우측: 거래처 목록 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedItem?.item_number) { setCustomerItems([]); return; }
|
if (!selectedItem?.item_number) { setCustomerItems([]); setCustomerCheckedIds([]); return; }
|
||||||
|
setCustomerCheckedIds([]);
|
||||||
const itemKey = selectedItem.item_number;
|
const itemKey = selectedItem.item_number;
|
||||||
const fetchCustomerItems = async () => {
|
const fetchCustomerItems = async () => {
|
||||||
setCustomerLoading(true);
|
setCustomerLoading(true);
|
||||||
try {
|
try {
|
||||||
// customer_item_mapping에서 해당 품목의 매핑 조회
|
// 1. customer_item_mapping에서 해당 품목의 매핑 조회
|
||||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||||
page: 1, size: 500,
|
page: 1, size: 500,
|
||||||
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
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 || [];
|
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))];
|
const custIds = [...new Set(mappings.map((m: any) => m.customer_id).filter(Boolean))];
|
||||||
let custMap: Record<string, any> = {};
|
let custMap: Record<string, any> = {};
|
||||||
if (custIds.length > 0) {
|
if (custIds.length > 0) {
|
||||||
@@ -229,11 +231,54 @@ export default function SalesItemPage() {
|
|||||||
} catch { /* skip */ }
|
} catch { /* skip */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
setCustomerItems(mappings.map((m: any) => ({
|
// 3. customer_item_prices 조회 (단가 정보)
|
||||||
...m,
|
let allPrices: any[] = [];
|
||||||
customer_code: m.customer_id,
|
if (mappings.length > 0) {
|
||||||
customer_name: custMap[m.customer_id]?.customer_name || "",
|
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<string>();
|
||||||
|
|
||||||
|
// 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) {
|
} catch (err) {
|
||||||
console.error("거래처 조회 실패:", err);
|
console.error("거래처 조회 실패:", err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -241,7 +286,7 @@ export default function SalesItemPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchCustomerItems();
|
fetchCustomerItems();
|
||||||
}, [selectedItem?.item_number]);
|
}, [selectedItem?.item_number, priceCategoryOptions]);
|
||||||
|
|
||||||
// 거래처 검색
|
// 거래처 검색
|
||||||
const searchCustomers = async () => {
|
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 () => {
|
const handleExcelDownload = async () => {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
@@ -598,10 +683,16 @@ export default function SalesItemPage() {
|
|||||||
<Users className="w-4 h-4" /> 거래처 정보
|
<Users className="w-4 h-4" /> 거래처 정보
|
||||||
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
|
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" disabled={!selectedItemId}
|
<div className="flex gap-1.5">
|
||||||
onClick={() => { setCustCheckedIds(new Set()); setCustSelectOpen(true); searchCustomers(); }}>
|
<Button variant="outline" size="sm" disabled={!selectedItemId}
|
||||||
<Plus className="w-3.5 h-3.5 mr-1" /> 거래처 추가
|
onClick={() => { setCustCheckedIds(new Set()); setCustSelectOpen(true); searchCustomers(); }}>
|
||||||
</Button>
|
<Plus className="w-3.5 h-3.5 mr-1" /> 거래처 추가
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" disabled={customerCheckedIds.length === 0}
|
||||||
|
onClick={handleCustomerMappingDelete}>
|
||||||
|
<Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!selectedItemId ? (
|
{!selectedItemId ? (
|
||||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||||
@@ -614,6 +705,9 @@ export default function SalesItemPage() {
|
|||||||
data={customerItems}
|
data={customerItems}
|
||||||
loading={customerLoading}
|
loading={customerLoading}
|
||||||
showRowNumber={false}
|
showRowNumber={false}
|
||||||
|
showCheckbox
|
||||||
|
checkedIds={customerCheckedIds}
|
||||||
|
onCheckedChange={setCustomerCheckedIds}
|
||||||
emptyMessage="등록된 거래처가 없습니다"
|
emptyMessage="등록된 거래처가 없습니다"
|
||||||
onRowDoubleClick={(row) => openEditCust(row)}
|
onRowDoubleClick={(row) => openEditCust(row)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export default function ShippingOrderPage() {
|
|||||||
const [orders, setOrders] = useState<any[]>([]);
|
const [orders, setOrders] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
||||||
|
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
|
||||||
|
|
||||||
// 검색
|
// 검색
|
||||||
const [searchKeyword, setSearchKeyword] = useState("");
|
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) : [];
|
const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : [];
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<TableRow key={order.id} className="cursor-pointer hover:bg-muted/50" onClick={() => openModal(order)}>
|
<TableRow key={order.id} className={cn("cursor-pointer hover:bg-muted/50", selectedOrderId === order.id && "bg-primary/5")} onClick={() => setSelectedOrderId(order.id)} onDoubleClick={() => openModal(order)}>
|
||||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
||||||
<Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
|
<Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
|
||||||
if (c) setCheckedIds(p => [...p, order.id]);
|
if (c) setCheckedIds(p => [...p, order.id]);
|
||||||
@@ -551,7 +552,7 @@ export default function ShippingOrderPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return items.map((item: any, itemIdx: number) => (
|
return items.map((item: any, itemIdx: number) => (
|
||||||
<TableRow key={`${order.id}-${item.id}`} className="cursor-pointer hover:bg-muted/50" onClick={() => openModal(order)}>
|
<TableRow key={`${order.id}-${item.id}`} className={cn("cursor-pointer hover:bg-muted/50", selectedOrderId === order.id && "bg-primary/5")} onClick={() => setSelectedOrderId(order.id)} onDoubleClick={() => openModal(order)}>
|
||||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
||||||
{itemIdx === 0 && <Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
|
{itemIdx === 0 && <Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
|
||||||
if (c) setCheckedIds(p => [...p, order.id]);
|
if (c) setCheckedIds(p => [...p, order.id]);
|
||||||
|
|||||||
@@ -1,29 +1,87 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { DndProvider } from "react-dnd";
|
import { DndProvider } from "react-dnd";
|
||||||
|
import { useTabStore } from "@/stores/tabStore";
|
||||||
|
import { useTabId } from "@/contexts/TabIdContext";
|
||||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||||
import { ReportDesignerToolbar } from "@/components/report/designer/ReportDesignerToolbar";
|
import { ReportDesignerToolbar } from "@/components/report/designer/ReportDesignerToolbar";
|
||||||
import { PageListPanel } from "@/components/report/designer/PageListPanel";
|
import { PageListPanel } from "@/components/report/designer/PageListPanel";
|
||||||
import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel";
|
import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel";
|
||||||
import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas";
|
import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas";
|
||||||
import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel";
|
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 { reportApi } from "@/lib/api/reportApi";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export default function ReportDesignerPage() {
|
const BREAKPOINT_COLLAPSE_LEFT = 1200;
|
||||||
const params = useParams();
|
const BREAKPOINT_COLLAPSE_ALL = 900;
|
||||||
const router = useRouter();
|
|
||||||
const reportId = params.reportId as string;
|
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 (
|
||||||
|
<div className="flex h-screen min-w-[768px] flex-col overflow-hidden bg-gray-50">
|
||||||
|
<ReportDesignerToolbar />
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
<PageListPanel />
|
||||||
|
<ReportDesignerLeftPanel />
|
||||||
|
<ReportDesignerCanvas />
|
||||||
|
<ReportDesignerRightPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ComponentSettingsModal />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [isLoading, setIsLoading] = useState(true);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const closeTab = useTabStore((s) => s.closeTab);
|
||||||
|
const currentTabId = useTabId();
|
||||||
|
|
||||||
|
const closeDesignerTab = useCallback(() => {
|
||||||
|
if (currentTabId) {
|
||||||
|
closeTab(currentTabId);
|
||||||
|
}
|
||||||
|
}, [currentTabId, closeTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadReport = async () => {
|
const loadReport = async () => {
|
||||||
// 'new'는 새 리포트 생성 모드
|
|
||||||
if (reportId === "new") {
|
if (reportId === "new") {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
@@ -37,7 +95,7 @@ export default function ReportDesignerPage() {
|
|||||||
description: "리포트를 찾을 수 없습니다.",
|
description: "리포트를 찾을 수 없습니다.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
router.push("/admin/screenMng/reportList");
|
closeDesignerTab();
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast({
|
toast({
|
||||||
@@ -45,7 +103,7 @@ export default function ReportDesignerPage() {
|
|||||||
description: error.message || "리포트를 불러오는데 실패했습니다.",
|
description: error.message || "리포트를 불러오는데 실패했습니다.",
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
router.push("/admin/screenMng/reportList");
|
closeDesignerTab();
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -54,7 +112,7 @@ export default function ReportDesignerPage() {
|
|||||||
if (reportId) {
|
if (reportId) {
|
||||||
loadReport();
|
loadReport();
|
||||||
}
|
}
|
||||||
}, [reportId, router, toast]);
|
}, [reportId, closeDesignerTab, toast]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -65,28 +123,12 @@ export default function ReportDesignerPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndProvider backend={HTML5Backend}>
|
<div id="report-designer-dnd-root">
|
||||||
<ReportDesignerProvider reportId={reportId}>
|
<DndProvider backend={HTML5Backend}>
|
||||||
<div className="flex h-screen flex-col overflow-hidden bg-muted">
|
<ReportDesignerProvider reportId={reportId}>
|
||||||
{/* 상단 툴바 */}
|
<DesignerLayout />
|
||||||
<ReportDesignerToolbar />
|
</ReportDesignerProvider>
|
||||||
|
</DndProvider>
|
||||||
{/* 메인 영역 */}
|
</div>
|
||||||
<div className="flex flex-1 overflow-hidden">
|
|
||||||
{/* 페이지 목록 패널 */}
|
|
||||||
<PageListPanel />
|
|
||||||
|
|
||||||
{/* 좌측 패널 (템플릿, 컴포넌트) */}
|
|
||||||
<ReportDesignerLeftPanel />
|
|
||||||
|
|
||||||
{/* 중앙 캔버스 */}
|
|
||||||
<ReportDesignerCanvas />
|
|
||||||
|
|
||||||
{/* 우측 패널 (속성) */}
|
|
||||||
<ReportDesignerRightPanel />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ReportDesignerProvider>
|
|
||||||
</DndProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +1,528 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { ReportListTable } from "@/components/report/ReportListTable";
|
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 { 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() {
|
export default function ReportManagementPage() {
|
||||||
const router = useRouter();
|
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [viewMode, setViewMode] = useState<"grid" | "list">("list");
|
||||||
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
|
const [copyTarget, setCopyTarget] = useState<ReportMaster | null>(null);
|
||||||
|
const [viewTarget, setViewTarget] = useState<ReportMaster | null>(null);
|
||||||
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
const [datePopoverOpen, setDatePopoverOpen] = useState(false);
|
||||||
|
const [tempStartDate, setTempStartDate] = useState<Date | undefined>(undefined);
|
||||||
|
const [tempEndDate, setTempEndDate] = useState<Date | undefined>(undefined);
|
||||||
|
const filterRef = useRef<HTMLDivElement>(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 = () => {
|
useEffect(() => {
|
||||||
handleSearch(searchText);
|
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 = () => {
|
const isDateFilterActive = searchField === "updated_at" && startDate && endDate;
|
||||||
setSearchText("");
|
|
||||||
handleSearch("");
|
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<string, number>();
|
||||||
|
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 = () => {
|
const handleCreateNew = () => {
|
||||||
// 새 리포트는 'new'라는 특수 ID로 디자이너 진입
|
setIsCreateOpen(true);
|
||||||
router.push("/admin/screenMng/reportList/designer/new");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const currentFieldLabel = SEARCH_FIELD_OPTIONS.find((o) => o.value === searchField)?.label ?? "리포트명";
|
||||||
<div className="min-h-screen bg-muted">
|
|
||||||
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
|
||||||
{/* 페이지 제목 */}
|
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-foreground">리포트 관리</h1>
|
|
||||||
<p className="mt-2 text-muted-foreground">리포트를 생성하고 관리합니다</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleCreateNew} className="gap-2">
|
|
||||||
<Plus className="h-4 w-4" />새 리포트
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 검색 영역 */}
|
return (
|
||||||
<Card className="shadow-sm">
|
<div className="report-page-content flex h-[calc(100vh-56px)] flex-col bg-gray-50">
|
||||||
<CardHeader className="bg-muted/50">
|
<div className="shrink-0 border-b bg-white">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<div className="mx-6 py-2.5">
|
||||||
<Search className="h-5 w-5" />
|
<div className="mb-2 flex items-center justify-between">
|
||||||
검색
|
<div className="flex items-center gap-3">
|
||||||
</CardTitle>
|
<h1 className="text-xl font-bold text-gray-900">리포트 관리</h1>
|
||||||
</CardHeader>
|
<span className="text-sm text-gray-400">리포트를 생성하고 관리합니다</span>
|
||||||
<CardContent className="pt-6">
|
</div>
|
||||||
<div className="flex gap-2">
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="relative w-full sm:w-[480px]">
|
||||||
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="리포트명으로 검색..."
|
placeholder={`${currentFieldLabel}(으)로 검색...`}
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => e.key === "Enter" && handleSearchClick()}
|
||||||
if (e.key === "Enter") {
|
className="h-9 pl-9 text-sm"
|
||||||
handleSearchClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleSearchClick} className="gap-2">
|
</div>
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
검색
|
<Button
|
||||||
|
onClick={handleSearchClick}
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 w-9 shrink-0"
|
||||||
|
title="검색"
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div ref={filterRef} className="relative">
|
||||||
|
<Button
|
||||||
|
onClick={() => setFilterOpen(!filterOpen)}
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
className={`h-9 w-9 shrink-0 ${filterOpen || isDateFilterActive ? "border-blue-400 bg-blue-50 text-blue-600" : ""}`}
|
||||||
|
title="검색 필터"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleReset} variant="outline" className="gap-2">
|
|
||||||
<RotateCcw className="h-4 w-4" />
|
{filterOpen && !datePopoverOpen && (
|
||||||
초기화
|
<div className="absolute top-full right-0 z-50 mt-1.5 w-52 rounded-lg border border-gray-200 bg-white py-1.5 shadow-lg">
|
||||||
|
<div className="px-3.5 py-2 text-sm font-semibold text-gray-400">검색 기준</div>
|
||||||
|
{SEARCH_FIELD_OPTIONS.map((opt) => {
|
||||||
|
const Icon = opt.icon;
|
||||||
|
const isDateOption = opt.value === "updated_at";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => {
|
||||||
|
if (isDateOption) {
|
||||||
|
setDatePopoverOpen(true);
|
||||||
|
} else {
|
||||||
|
handleSearchFieldChange(opt.value);
|
||||||
|
handleDateRangeChange("", "");
|
||||||
|
setFilterOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-base transition-colors hover:bg-gray-50 ${
|
||||||
|
searchField === opt.value || (isDateOption && searchField === "updated_at")
|
||||||
|
? "font-medium text-blue-600"
|
||||||
|
: "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="flex-1 text-left">{opt.label}</span>
|
||||||
|
{isDateOption && <span className="text-xs text-gray-400">▸</span>}
|
||||||
|
{!isDateOption && searchField === opt.value && (
|
||||||
|
<Check className="h-4.5 w-4.5 shrink-0 text-blue-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filterOpen && datePopoverOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute top-full right-0 z-50 mt-1.5 rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setDatePopoverOpen(false)}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
← 뒤로
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-semibold text-gray-700">기간 검색</span>
|
||||||
|
<div className="w-10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{[
|
||||||
|
{ label: "오늘", action: () => handleDatePreset(0) },
|
||||||
|
{ label: "1주일", action: () => handleDatePreset(7) },
|
||||||
|
{ label: "1개월", action: () => handleMonthPreset(1) },
|
||||||
|
{ label: "3개월", action: () => handleMonthPreset(3) },
|
||||||
|
].map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
onClick={preset.action}
|
||||||
|
className="flex-1 rounded-md border border-gray-200 px-2 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-500">시작일</label>
|
||||||
|
<div className="flex h-9 w-full items-center rounded-md border border-gray-200 px-3 text-sm font-medium text-gray-700">
|
||||||
|
<CalendarDays className="mr-2 h-3.5 w-3.5 text-gray-400" />
|
||||||
|
{tempStartDate ? (
|
||||||
|
format(tempStartDate, "yyyy-MM-dd")
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">선택</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={tempStartDate}
|
||||||
|
onSelect={setTempStartDate}
|
||||||
|
locale={ko}
|
||||||
|
className="mt-1.5 rounded-md border border-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center pt-6">
|
||||||
|
<span className="text-sm text-gray-400">~</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-gray-500">종료일</label>
|
||||||
|
<div className="flex h-9 w-full items-center rounded-md border border-gray-200 px-3 text-sm font-medium text-gray-700">
|
||||||
|
<CalendarDays className="mr-2 h-3.5 w-3.5 text-gray-400" />
|
||||||
|
{tempEndDate ? (
|
||||||
|
format(tempEndDate, "yyyy-MM-dd")
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">선택</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={tempEndDate}
|
||||||
|
onSelect={setTempEndDate}
|
||||||
|
locale={ko}
|
||||||
|
className="mt-1.5 rounded-md border border-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleApplyDateFilter}
|
||||||
|
disabled={!tempStartDate || !tempEndDate}
|
||||||
|
className="h-9 w-full bg-blue-600 text-sm text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
적용
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isDateFilterActive && (
|
||||||
|
<div className="flex items-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5">
|
||||||
|
<CalendarDays className="h-4 w-4 text-blue-600" />
|
||||||
|
<span className="text-sm font-medium text-blue-700">
|
||||||
|
{startDate} ~ {endDate}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleClearDateFilter}
|
||||||
|
className="ml-1 rounded p-0.5 text-blue-400 transition-colors hover:bg-blue-100 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center overflow-hidden rounded-md border border-gray-200">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleViewModeChange("list")}
|
||||||
|
className={`h-9 w-9 rounded-none ${viewMode === "list" ? "bg-gray-100" : ""}`}
|
||||||
|
title="리스트 보기"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleViewModeChange("grid")}
|
||||||
|
className={`h-9 w-9 rounded-none border-l ${viewMode === "grid" ? "bg-gray-100" : ""}`}
|
||||||
|
title="그리드 보기"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 리포트 목록 */}
|
<Button
|
||||||
<Card className="shadow-sm">
|
onClick={handleCreateNew}
|
||||||
<CardHeader className="bg-muted/50">
|
className="ml-auto h-9 gap-1.5 bg-blue-600 text-sm text-white hover:bg-blue-700"
|
||||||
<CardTitle className="flex items-center justify-between">
|
>
|
||||||
<span className="flex items-center gap-2">
|
<Plus className="h-4 w-4" />새 리포트
|
||||||
📋 리포트 목록
|
</Button>
|
||||||
<span className="text-muted-foreground text-sm font-normal">(총 {total}건)</span>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-5 px-8 py-5">
|
||||||
|
<div className="grid shrink-0 auto-rows-fr grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div className="flex items-center justify-center gap-5 overflow-hidden rounded-xl border border-gray-100 bg-white px-6 py-8 transition-all hover:border-gray-200 hover:shadow-sm">
|
||||||
|
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-blue-50">
|
||||||
|
<FileText className="h-8 w-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-lg font-bold whitespace-nowrap text-gray-500">전체 리포트</span>
|
||||||
|
<p className="mt-1 text-5xl font-bold tracking-tight whitespace-nowrap text-gray-900 tabular-nums">
|
||||||
|
{total.toLocaleString()}
|
||||||
|
<span className="ml-1.5 text-xl font-bold text-gray-400">건</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex items-center justify-center gap-8 rounded-xl border border-gray-100 bg-white px-6 py-8 transition-all hover:border-gray-200 hover:shadow-sm">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-purple-50">
|
||||||
|
<Users className="h-8 w-8 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-lg font-bold whitespace-nowrap text-gray-500">작성자</span>
|
||||||
|
<p className="mt-1 text-5xl font-bold tracking-tight whitespace-nowrap text-gray-900 tabular-nums">
|
||||||
|
{authorCount.toLocaleString()}
|
||||||
|
<span className="ml-1.5 text-xl font-bold text-gray-400">명</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{authorStats.length > 0 && (
|
||||||
|
<div className="h-[80px] w-[100px] shrink-0" style={{ overflow: "visible" }}>
|
||||||
|
<BarChart width={100} height={80} data={authorStats} margin={{ top: 12, right: 0, bottom: 0, left: 0 }}>
|
||||||
|
<Bar
|
||||||
|
dataKey="count" fill="#a78bfa" radius={[3, 3, 0, 0]} maxBarSize={18}
|
||||||
|
isAnimationActive={true} animationDuration={1200} animationEasing="ease-out"
|
||||||
|
>
|
||||||
|
<LabelList dataKey="count" position="top" style={{ fontSize: "10px", fontWeight: 700, fill: "#6d28d9" }} />
|
||||||
|
</Bar>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`${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 }}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center rounded-xl border border-gray-100 bg-white px-6 py-6 transition-all hover:border-gray-200 hover:shadow-sm">
|
||||||
|
<span className="text-lg font-bold whitespace-nowrap text-gray-500">
|
||||||
|
최근 30일 활동{" "}
|
||||||
|
<span className="text-base font-semibold text-gray-400 tabular-nums">
|
||||||
|
({recentTotal.toLocaleString()}건)
|
||||||
</span>
|
</span>
|
||||||
</CardTitle>
|
</span>
|
||||||
</CardHeader>
|
<div className="mt-2 flex w-full flex-1 items-center justify-center">
|
||||||
<CardContent className="p-0">
|
<div className="shrink-0" style={{ overflow: "visible" }}>
|
||||||
|
<BarChart
|
||||||
|
width={220} height={100} data={recentActivity}
|
||||||
|
barCategoryGap="12%" margin={{ top: 16, right: 2, bottom: 0, left: 2 }}
|
||||||
|
>
|
||||||
|
<XAxis dataKey="date" tick={{ fontSize: 12, fill: "#374151", fontWeight: 700 }} axisLine={false} tickLine={false} />
|
||||||
|
<Bar
|
||||||
|
dataKey="count" fill="#60a5fa" radius={[4, 4, 0, 0]} maxBarSize={32}
|
||||||
|
isAnimationActive={true} animationDuration={1200} animationEasing="ease-out"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`${value}건`, "수정"]}
|
||||||
|
contentStyle={{ fontSize: "12px", borderRadius: "6px", boxShadow: "0 2px 8px rgba(0,0,0,0.12)" }}
|
||||||
|
cursor={false}
|
||||||
|
allowEscapeViewBox={{ x: true, y: true }}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center rounded-xl border border-gray-100 bg-white px-6 py-6 transition-all hover:border-gray-200 hover:shadow-sm">
|
||||||
|
<span className="text-lg font-bold whitespace-nowrap text-gray-500">카테고리별 분포</span>
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
{typeData.length === 0 ? (
|
||||||
|
<p className="text-lg font-bold text-gray-400">데이터 없음</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2.5">
|
||||||
|
<div className="shrink-0" style={{ overflow: "visible", position: "relative" }}>
|
||||||
|
<PieChart width={90} height={90} style={{ overflow: "visible" }}>
|
||||||
|
<Pie
|
||||||
|
data={typeData} cx="50%" cy="50%" innerRadius={20} outerRadius={40}
|
||||||
|
dataKey="value" nameKey="type" startAngle={90} endAngle={-270}
|
||||||
|
strokeWidth={2} stroke="#fff"
|
||||||
|
isAnimationActive={true} animationDuration={1200} animationEasing="ease-out"
|
||||||
|
>
|
||||||
|
{typeData.map((entry) => (
|
||||||
|
<Cell key={entry.type} fill={REPORT_TYPE_COLORS[getTypeColorIndex(entry.type) % REPORT_TYPE_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number, name: string) => [`${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 }}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
|
||||||
|
{typeData.slice(0, 4).map((entry) => {
|
||||||
|
const TypeIcon = getTypeIcon(entry.type);
|
||||||
|
return (
|
||||||
|
<div key={entry.type} className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="flex h-4 w-4 shrink-0 items-center justify-center rounded"
|
||||||
|
style={{ backgroundColor: REPORT_TYPE_COLORS[getTypeColorIndex(entry.type) % REPORT_TYPE_COLORS.length] }}
|
||||||
|
>
|
||||||
|
<TypeIcon className="h-2.5 w-2.5 text-white" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium whitespace-nowrap text-gray-600">{getTypeLabel(entry.type)}</span>
|
||||||
|
<span className="text-sm font-bold whitespace-nowrap text-gray-900">{entry.value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{typeData.length > 4 && <span className="text-xs text-gray-400">외 {typeData.length - 4}개</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50/50 px-5 py-3">
|
||||||
|
<span className="flex items-center gap-2.5 text-base font-semibold text-gray-900">
|
||||||
|
리포트 목록
|
||||||
|
<span className="text-sm font-normal text-gray-400">(총 {total}건)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
<ReportListTable
|
<ReportListTable
|
||||||
reports={reports}
|
reports={reports}
|
||||||
total={total}
|
total={total}
|
||||||
page={page}
|
page={page}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
viewMode={viewMode}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
onRefresh={refetch}
|
onRefresh={refetch}
|
||||||
|
onViewClick={setViewTarget}
|
||||||
|
onCopyClick={setCopyTarget}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ReportCreateModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} onSuccess={refetch} />
|
||||||
|
|
||||||
|
<ReportListPreviewModal report={viewTarget} onClose={() => setViewTarget(null)} />
|
||||||
|
|
||||||
|
{copyTarget && (
|
||||||
|
<ReportCopyModal
|
||||||
|
report={copyTarget}
|
||||||
|
onClose={() => setCopyTarget(null)}
|
||||||
|
onSuccess={() => {
|
||||||
|
setCopyTarget(null);
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FC]">
|
||||||
|
{/* Navigation Bar */}
|
||||||
|
<div className="bg-[#1E3A5F] border-b-4 border-[#0F172A] px-6 py-3 print:hidden">
|
||||||
|
<div className="max-w-[842px] mx-auto flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/admin/screenMng/reportList/samples"
|
||||||
|
className="flex items-center gap-2 text-white hover:text-[#EFF6FF] transition-colors border-2 border-white px-3 py-1"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
<span className="text-sm">돌아가기</span>
|
||||||
|
</Link>
|
||||||
|
<div className="h-6 w-px bg-[#64748B]" />
|
||||||
|
<h1 className="text-lg text-white">{title}</h1>
|
||||||
|
{docNumber && (
|
||||||
|
<span className="text-xs text-[#94A3B8] border border-[#475569] px-2 py-0.5">{docNumber}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white text-[#1E3A5F] border-2 border-white hover:bg-[#EFF6FF] transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Printer className="w-4 h-4" />
|
||||||
|
인쇄
|
||||||
|
</button>
|
||||||
|
<button className="flex items-center gap-2 px-4 py-2 border-2 border-white text-white hover:bg-[#2563EB] hover:border-[#2563EB] transition-colors text-sm">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
다운로드
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Container */}
|
||||||
|
<div className="py-8 px-4">
|
||||||
|
<div className="max-w-[842px] mx-auto bg-white border-4 border-[#1E3A5F] print:border-2">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
type StatusType = "합격" | "불합격" | "보류" | "발주완료" | "검토중" | "취소" | "완료" | "승인대기";
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: StatusType;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOR_MAP: Record<StatusType, string> = {
|
||||||
|
합격: "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 (
|
||||||
|
<span className={`inline-flex items-center justify-center border-2 ${COLOR_MAP[status]} ${SIZE_MAP[size]}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="border-2 border-[#1E3A5F]">
|
||||||
|
<div className="bg-[#EFF6FF] border-b-2 border-[#1E3A5F] px-4 py-2">
|
||||||
|
<h3 className="text-sm text-[#0F172A]">▣ {title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-2 text-xs">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[100px,1fr] border-b border-[#E2E8F0] pb-1">
|
||||||
|
<span className="text-[#64748B]">{label}</span>
|
||||||
|
<span className={highlight ? "text-[#2563EB]" : "text-[#0F172A]"}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 결재란 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ApprovalSection({ columns }: { columns: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
|
<div className="border-2 border-[#1E3A5F]">
|
||||||
|
<div className="grid text-center text-xs" style={{ gridTemplateColumns: `repeat(${columns.length}, 96px)` }}>
|
||||||
|
{columns.map((col, i) => (
|
||||||
|
<div key={i} className={`p-3 ${i < columns.length - 1 ? "border-r-2 border-[#1E3A5F]" : ""}`}>
|
||||||
|
<div className="text-[#64748B] mb-6 pb-2 border-b border-[#E2E8F0]">{col}</div>
|
||||||
|
<div className="h-12" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InspectionReportPage() {
|
||||||
|
return (
|
||||||
|
<DocumentLayout title="검사 보고서" docNumber="IR-2026-00123">
|
||||||
|
<div className="p-10">
|
||||||
|
{/* ── 헤더 ── */}
|
||||||
|
<div className="border-4 border-[#1E3A5F] mb-6">
|
||||||
|
<div className="bg-[#1E3A5F] text-white px-6 py-4 text-center">
|
||||||
|
<h1 className="text-3xl tracking-widest">검 사 보 고 서</h1>
|
||||||
|
<p className="text-xs mt-1 tracking-wider">INSPECTION REPORT</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white px-6 py-3 flex justify-between items-center border-t-2 border-[#1E3A5F]">
|
||||||
|
<div className="text-sm text-[#64748B]">문서번호: IR-2026-00123</div>
|
||||||
|
<StatusBadge status="합격" size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 기본 정보 (2열 카드) ── */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||||
|
<InfoCard title="검사 대상">
|
||||||
|
<InfoRow label="발행번호" value="HC2014 - 005" />
|
||||||
|
<InfoRow label="협력업체" value="매직볼드" />
|
||||||
|
<InfoRow label="규격명" value="SATA-234" highlight />
|
||||||
|
<InfoRow label="수주계측기" value="버니어캘리퍼스 (Serial No.) #05233911" />
|
||||||
|
<InfoRow label="검사전환일" value="저울 (Serial No.) #258-98-22" />
|
||||||
|
</InfoCard>
|
||||||
|
|
||||||
|
<InfoCard title="검사 정보">
|
||||||
|
<InfoRow label="생산일자" value="2014-03-10" highlight />
|
||||||
|
<InfoRow label="검사수량" value="565" />
|
||||||
|
<InfoRow label="검사레벨" value="일반검사1" />
|
||||||
|
<InfoRow label="AQL" value="1.5" />
|
||||||
|
<InfoRow label="검사일자" value="2014-03-10" highlight />
|
||||||
|
<InfoRow label="시료수량" value="8" />
|
||||||
|
<InfoRow label="검사자" value="김수로" />
|
||||||
|
</InfoCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 검사 항목 테이블 ── */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="bg-[#EFF6FF] border-2 border-[#1E3A5F] border-b-0 px-4 py-2">
|
||||||
|
<h3 className="text-sm text-[#0F172A]">▣ 검사/시험 측정값</h3>
|
||||||
|
</div>
|
||||||
|
<div className="border-2 border-[#1E3A5F]">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#1E3A5F] text-white">
|
||||||
|
<th className="px-3 py-2 text-center border-r-2 border-white" rowSpan={2}>검사항목</th>
|
||||||
|
<th className="px-3 py-2 text-center border-r-2 border-white" rowSpan={2}>
|
||||||
|
시험 및 검사대응<br />(검사기준)
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-center border-r-2 border-white" colSpan={8}>
|
||||||
|
검사/시험 측정값
|
||||||
|
</th>
|
||||||
|
<th className="px-3 py-2 text-center border-r-2 border-white" rowSpan={2}>X̄</th>
|
||||||
|
<th className="px-3 py-2 text-center" rowSpan={2}>합격 판정</th>
|
||||||
|
</tr>
|
||||||
|
<tr className="bg-[#1E3A5F] text-white border-t-2 border-white">
|
||||||
|
{["X1", "X2", "X3", "X4", "X5", "X6", "X7", "X8"].map((x) => (
|
||||||
|
<th key={x} className="px-2 py-2 text-center border-r-2 border-white">{x}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{INSPECTION_ITEMS.map((item, idx) => (
|
||||||
|
<tr key={item.no} className={`border-t border-[#E2E8F0] ${idx % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||||
|
<td className="px-3 py-2 border-r border-[#E2E8F0]">
|
||||||
|
<div>{item.item}</div>
|
||||||
|
{item.subItem && <div className="text-[#64748B]">{item.subItem}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 border-r border-[#E2E8F0]">
|
||||||
|
<div>{item.method}</div>
|
||||||
|
{(item.method === "길이" || item.method === "폭") && (
|
||||||
|
<div className="text-[#64748B] mt-1">{item.standard}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
{item.measured.map((val, i) => (
|
||||||
|
<td key={i} className="px-2 py-2 text-center border-r border-[#E2E8F0]">{val}</td>
|
||||||
|
))}
|
||||||
|
<td className="px-3 py-2 text-center border-r border-[#E2E8F0]">8</td>
|
||||||
|
<td className="px-3 py-2 text-center">
|
||||||
|
<StatusBadge status={item.result} size="sm" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 범례 */}
|
||||||
|
<div className="mt-3 flex items-center gap-6 text-xs text-[#64748B]">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-1 border-2 border-[#D97706] bg-yellow-100 text-[#0F172A]">비 고</span>
|
||||||
|
<span>[범례] A : Accept, R : Reject, H : Hold</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>중량판정</span>
|
||||||
|
<span className="px-2 py-1 bg-[#1E3A5F] text-white border-2 border-[#1E3A5F]">■ 합 격</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 결재란 ── */}
|
||||||
|
<ApprovalSection columns={["작성", "검토", "승인"]} />
|
||||||
|
|
||||||
|
{/* ── 푸터 ── */}
|
||||||
|
<div className="text-xs text-[#64748B] flex justify-between items-center pt-4 border-t-2 border-[#1E3A5F]">
|
||||||
|
<div>양식번호 : QF-805-2 (Rev.0)</div>
|
||||||
|
<div>A4(210mm×297mm)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-[#F8F9FC]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-[#1E3A5F] border-b-4 border-[#0F172A] px-6 py-3">
|
||||||
|
<div className="max-w-5xl mx-auto flex items-center gap-4">
|
||||||
|
<Link
|
||||||
|
href="/admin/screenMng/reportList"
|
||||||
|
className="flex items-center gap-2 text-white hover:text-[#EFF6FF] transition-colors border-2 border-white px-3 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
리포트 목록
|
||||||
|
</Link>
|
||||||
|
<div className="h-6 w-px bg-[#64748B]" />
|
||||||
|
<h1 className="text-white text-lg">리포트 디자인 샘플</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="py-10 px-6">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
{/* Title Section */}
|
||||||
|
<div className="bg-white border-4 border-[#1E3A5F] p-8 mb-8 text-center">
|
||||||
|
<h2 className="text-3xl text-[#0F172A] border-b-4 border-[#2563EB] pb-4 mb-4">
|
||||||
|
WACE PLM — 문서 양식 샘플
|
||||||
|
</h2>
|
||||||
|
<p className="text-[#64748B] text-sm">
|
||||||
|
리포트 디자이너에서 활용 가능한 표준 문서 양식 샘플입니다.
|
||||||
|
<br />
|
||||||
|
카드(정보패널), 테이블, 결재란 등 기본 컴포넌트로 구성되었습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sample Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{SAMPLES.map((sample) => (
|
||||||
|
<Link
|
||||||
|
key={sample.path}
|
||||||
|
href={sample.path}
|
||||||
|
className="bg-white border-2 border-[#1E3A5F] hover:bg-[#EFF6FF] transition-colors group block"
|
||||||
|
>
|
||||||
|
<div className="border-b-2 border-[#1E3A5F] bg-[#EFF6FF] p-5 text-center group-hover:bg-[#DBEAFE] transition-colors">
|
||||||
|
<sample.icon className="w-10 h-10 mx-auto text-[#2563EB] mb-2" />
|
||||||
|
<p className="text-xs text-[#64748B]">{sample.docNo}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-xl text-[#0F172A] text-center border-b border-[#E2E8F0] pb-2 mb-3">
|
||||||
|
{sample.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[#64748B] text-center mb-1">{sample.titleEng}</p>
|
||||||
|
<p className="text-[#64748B] text-sm leading-relaxed text-center mt-3">
|
||||||
|
{sample.description}
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<span className="inline-block border-2 border-[#2563EB] px-4 py-2 text-sm text-[#2563EB] hover:bg-[#2563EB] hover:text-white transition-colors">
|
||||||
|
샘플 보기 →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 text-center bg-white border-2 border-[#1E3A5F] p-4">
|
||||||
|
<p className="text-[#64748B] text-xs">A4 인쇄 최적화 · WACE PLM 리포트 디자이너 v2.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<td className={`py-2 px-3 ${labelBg} border-r-2 border-[#1E3A5F] text-[#0F172A] w-28`}>{label}</td>
|
||||||
|
<td className={`py-2 px-3 text-[#64748B]`} colSpan={colSpan ?? 1}>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<DocumentLayout title="발주서" docNumber="PO-2026-00789">
|
||||||
|
<div className="p-10">
|
||||||
|
{/* ── 헤더 ── */}
|
||||||
|
<div className="border-4 border-[#1E3A5F] mb-6">
|
||||||
|
<div className="flex items-center justify-between bg-[#1E3A5F] text-white px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl tracking-[0.5em]">발 주 서</h1>
|
||||||
|
<p className="text-xs mt-1 tracking-wider">PURCHASE ORDER</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<StatusBadge status="발주완료" size="md" />
|
||||||
|
{/* 결재란 인라인 */}
|
||||||
|
<div className="border-2 border-white">
|
||||||
|
<div className="grid grid-cols-4 text-xs">
|
||||||
|
{["담당", "부서장", "임원", "사장"].map((col, i) => (
|
||||||
|
<div key={i} className={`px-3 py-2 text-center ${i < 3 ? "border-r-2 border-white" : ""}`}>
|
||||||
|
{col}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 문서 번호 ── */}
|
||||||
|
<div className="mb-6 text-right">
|
||||||
|
<div className="inline-block border-2 border-[#1E3A5F] px-6 py-2 bg-[#F8F9FC]">
|
||||||
|
<div className="text-sm text-[#64748B]">발주번호: PO-2026-00789</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 발주처 정보 카드 ── */}
|
||||||
|
<div className="mb-6 border-2 border-[#1E3A5F]">
|
||||||
|
<div className="bg-[#EFF6FF] border-b-2 border-[#1E3A5F] px-4 py-2 text-sm text-[#0F172A]">
|
||||||
|
▣ 발주처 정보
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b border-[#E2E8F0]">
|
||||||
|
<InfoRow label="수 신 처" />
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0] w-1/3" />
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] w-20 text-[#0F172A]">TEL</td>
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] w-20 text-[#0F172A]">담당</td>
|
||||||
|
<td className="py-2 px-3" />
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-[#E2E8F0]">
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F]" />
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">FAX</td>
|
||||||
|
<td className="py-2 px-3" colSpan={3} />
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-[#E2E8F0]">
|
||||||
|
<InfoRow label="발 신 처" />
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">TEL</td>
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">담당</td>
|
||||||
|
<td className="py-2 px-3" />
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b-2 border-[#1E3A5F]">
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F]" />
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">FAX</td>
|
||||||
|
<td className="py-2 px-3" colSpan={3} />
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-[#E2E8F0]">
|
||||||
|
<InfoRow label="납품일정" highlight />
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A]">TEL</td>
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A]">현장담당</td>
|
||||||
|
<td className="py-2 px-3" />
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-[#E2E8F0]">
|
||||||
|
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F]" />
|
||||||
|
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A]">FAX</td>
|
||||||
|
<td className="py-2 px-3" colSpan={3} />
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b-2 border-[#1E3A5F]">
|
||||||
|
<InfoRow label="납 기 일" highlight />
|
||||||
|
<td className="py-2 px-3 text-[#64748B]" colSpan={3}>20___년 ___월 ___일</td>
|
||||||
|
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A] w-20">인도조건</td>
|
||||||
|
<td className="py-2 px-3" />
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b border-[#E2E8F0]">
|
||||||
|
<InfoRow label="대금결제조건" highlight colSpan={5} />
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<InfoRow label="검 수 방 법" highlight colSpan={5} />
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 발주 내역 테이블 ── */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="bg-[#EFF6FF] border-2 border-[#1E3A5F] border-b-0 px-4 py-2 text-sm text-[#0F172A]">
|
||||||
|
▣ 발주 내역
|
||||||
|
</div>
|
||||||
|
<div className="border-2 border-[#1E3A5F]">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#1E3A5F] text-white">
|
||||||
|
{["NO", "품 명", "규격", "단위", "수량", "단가", "금액", "비고"].map((h, i) => (
|
||||||
|
<th key={i} className={`px-3 py-3 text-center ${i < 7 ? "border-r-2 border-white" : ""} ${i === 0 ? "w-12" : ""} ${i === 3 || i === 4 ? "w-16" : ""} ${i === 7 ? "w-20" : ""}`}>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ITEMS.map((item, idx) => (
|
||||||
|
<tr key={item.no} className={`border-b border-[#E2E8F0] ${idx % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||||
|
<td className="px-3 py-3 text-center border-r border-[#E2E8F0]">{item.no}</td>
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]">{item.name}</td>
|
||||||
|
<td className="px-3 py-3 text-center border-r border-[#E2E8F0]">{item.spec}</td>
|
||||||
|
<td className="px-3 py-3 text-center border-r border-[#E2E8F0]">{item.unit}</td>
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{item.qty.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{item.price.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{(item.qty * item.price).toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-center" />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{Array.from({ length: EMPTY_ROWS }).map((_, idx) => (
|
||||||
|
<tr key={`e${idx}`} className={`border-b border-[#E2E8F0] ${(ITEMS.length + idx) % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||||
|
<td className="px-3 py-3 text-center border-r border-[#E2E8F0] text-[#CBD5E1]">{ITEMS.length + idx + 1}</td>
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0] h-8" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3" />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 금액 요약 ── */}
|
||||||
|
<div className="border-2 border-[#1E3A5F] mb-6">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className="px-4 py-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-center w-32 text-[#0F172A]">공급가액</td>
|
||||||
|
<td className="px-4 py-3 text-right border-r-2 border-[#1E3A5F] text-[#0F172A]">₩ {totalAmount.toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-center w-32 text-[#0F172A]">부가세액</td>
|
||||||
|
<td className="px-4 py-3 text-right border-r-2 border-[#1E3A5F] text-[#0F172A]">₩ {tax.toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-3 bg-[#1E3A5F] text-white text-center w-32">합계금액</td>
|
||||||
|
<td className="px-4 py-3 text-right bg-[#1E3A5F] text-white">₩ {grandTotal.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 안내문 ── */}
|
||||||
|
<div className="border-2 border-[#1E3A5F] p-4 text-center mb-6 bg-[#F8F9FC]">
|
||||||
|
<p className="text-sm text-[#0F172A]">상기 자재를 발주하오니 납기를 준수하여 인도 바랍니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 푸터 ── */}
|
||||||
|
<div className="text-xs text-[#64748B] border-t-2 border-[#1E3A5F] pt-3 flex justify-between">
|
||||||
|
<div>양식번호: PO-001 (Rev.2)</div>
|
||||||
|
<div>문의: TEL 000-0000-0000 / FAX 000-0000-0000</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
|
<div className="border-2 border-[#1E3A5F]">
|
||||||
|
<div className="grid text-center text-xs" style={{ gridTemplateColumns: `repeat(${columns.length}, 80px)` }}>
|
||||||
|
{columns.map((col, i) => (
|
||||||
|
<div key={i} className={`p-3 ${i < columns.length - 1 ? "border-r-2 border-[#1E3A5F]" : ""}`}>
|
||||||
|
<div className="text-[#64748B] mb-6 pb-2 border-b border-[#E2E8F0]">{col}</div>
|
||||||
|
<div className="h-12" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<DocumentLayout title="견적서" docNumber="QT-2026-01234">
|
||||||
|
<div className="p-10">
|
||||||
|
{/* ── 헤더 ── */}
|
||||||
|
<div className="border-4 border-[#1E3A5F] mb-6">
|
||||||
|
<div className="bg-[#1E3A5F] text-white px-6 py-4 text-center">
|
||||||
|
<h1 className="text-4xl tracking-[0.5em]">견 적 서</h1>
|
||||||
|
<p className="text-xs mt-2 tracking-wider">QUOTATION</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 문서 번호 ── */}
|
||||||
|
<div className="mb-6 text-right">
|
||||||
|
<div className="inline-block border-2 border-[#1E3A5F] px-6 py-2 bg-[#F8F9FC]">
|
||||||
|
<div className="text-sm text-[#64748B]">문서번호: QT-2026-01234</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 날짜 / 수신 ── */}
|
||||||
|
<div className="mb-6 text-right">
|
||||||
|
<div className="inline-block text-sm">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<span className="border-b-2 border-[#2563EB] px-8 pb-1">2026</span>
|
||||||
|
<span className="text-[#64748B]">년</span>
|
||||||
|
<span className="border-b-2 border-[#2563EB] px-6 pb-1">03</span>
|
||||||
|
<span className="text-[#64748B]">월</span>
|
||||||
|
<span className="border-b-2 border-[#2563EB] px-6 pb-1">09</span>
|
||||||
|
<span className="text-[#64748B]">일</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-b-2 border-[#1E3A5F] pb-2 text-lg">
|
||||||
|
<span className="mr-8 text-[#0F172A]">(주) ○○○○</span>
|
||||||
|
<span className="text-[#0F172A]">귀하</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 견적명 / 공급자 (2열 카드) ── */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||||
|
<div className="border-2 border-[#1E3A5F]">
|
||||||
|
<div className="bg-[#1E3A5F] text-white px-4 py-2 text-sm text-center border-b-2 border-[#1E3A5F]">
|
||||||
|
견 적 명
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white h-16" />
|
||||||
|
</div>
|
||||||
|
<div className="border-2 border-[#1E3A5F]">
|
||||||
|
<div className="bg-[#1E3A5F] text-white px-4 py-2 text-sm text-center border-b-2 border-[#1E3A5F]">
|
||||||
|
공 급 자
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-white text-xs space-y-1">
|
||||||
|
<div className="grid grid-cols-2 gap-2 border-b border-[#E2E8F0] pb-1">
|
||||||
|
<span className="text-[#64748B]">등록번호</span>
|
||||||
|
<span className="text-[#64748B]">상호(법인명) / 성명</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 border-b border-[#E2E8F0] pb-1">
|
||||||
|
<span className="text-[#64748B]">업태 / 업종</span>
|
||||||
|
<span className="text-[#64748B]">주소</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-b border-[#E2E8F0] pb-1 text-[#64748B]">
|
||||||
|
전화번호 팩스
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 합계금액 ── */}
|
||||||
|
<div className="border-2 border-[#1E3A5F] mb-6">
|
||||||
|
<div className="bg-[#1E3A5F] text-white px-4 py-2 text-sm text-center">합 계 금 액</div>
|
||||||
|
<div className="p-4 bg-white text-center text-2xl border-t-2 border-[#1E3A5F] text-[#2563EB]">
|
||||||
|
₩ {total.toLocaleString()} 원정
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 품목 테이블 ── */}
|
||||||
|
<div className="mb-6 border-2 border-[#1E3A5F]">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#1E3A5F] text-white">
|
||||||
|
{["품번", "품명", "규격", "수량", "단가", "공급가액", "세액", "비고"].map((h, i) => (
|
||||||
|
<th key={i} className={`px-3 py-3 text-center ${i < 7 ? "border-r-2 border-white" : ""}`}>{h}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ITEMS.map((item, idx) => {
|
||||||
|
const amount = item.qty * item.price;
|
||||||
|
const itemTax = Math.round(amount * 0.1);
|
||||||
|
return (
|
||||||
|
<tr key={item.no} className={`border-t-2 border-[#E2E8F0] ${idx % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||||
|
<td className="px-2 py-3 text-center border-r border-[#E2E8F0]">{item.no}</td>
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]">{item.name}</td>
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]">{item.spec}</td>
|
||||||
|
<td className="px-2 py-3 text-right border-r border-[#E2E8F0]">{item.qty.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{item.price.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{amount.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{itemTax.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-center" />
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{Array.from({ length: EMPTY_ROWS }).map((_, idx) => (
|
||||||
|
<tr key={`e${idx}`} className={`border-t border-[#E2E8F0] ${(ITEMS.length + idx) % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
|
||||||
|
<td className="px-2 py-3 text-center border-r border-[#E2E8F0] text-[#CBD5E1]">{ITEMS.length + idx + 1}</td>
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0] h-10" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-2 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3" />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{/* 합계 행 */}
|
||||||
|
<tr className="border-t-2 border-[#1E3A5F] bg-[#EFF6FF]">
|
||||||
|
<td colSpan={3} className="px-4 py-3 text-center border-r-2 border-[#1E3A5F] text-[#0F172A]">합 계</td>
|
||||||
|
<td className="px-2 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0] text-[#2563EB]">{supplyAmount.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3 text-right border-r border-[#E2E8F0] text-[#2563EB]">{tax.toLocaleString()}</td>
|
||||||
|
<td className="px-3 py-3" />
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 금액 요약 (우측 정렬) ── */}
|
||||||
|
<div className="flex justify-end mb-6">
|
||||||
|
<div className="border-2 border-[#1E3A5F] min-w-[300px]">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
<tr className="border-b-2 border-[#E2E8F0]">
|
||||||
|
<td className="px-4 py-2 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">공급가액</td>
|
||||||
|
<td className="px-4 py-2 text-right text-[#0F172A]">₩ {supplyAmount.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="border-b-2 border-[#1E3A5F]">
|
||||||
|
<td className="px-4 py-2 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">부가세 (10%)</td>
|
||||||
|
<td className="px-4 py-2 text-right text-[#0F172A]">₩ {tax.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="bg-[#1E3A5F] text-white">
|
||||||
|
<td className="px-4 py-2 border-r-2 border-white">합계금액</td>
|
||||||
|
<td className="px-4 py-2 text-right">₩ {total.toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 안내문 ── */}
|
||||||
|
<div className="border-2 border-[#1E3A5F] p-4 mb-6 bg-[#F8F9FC]">
|
||||||
|
<p className="text-sm text-[#0F172A] mb-1">위와 같이 견적합니다.</p>
|
||||||
|
<p className="text-sm text-[#0F172A] mb-1">상기 견적서의 품목과 금액을 확인해 주시기 바랍니다.</p>
|
||||||
|
<p className="text-sm text-[#0F172A]">감사합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 결재란 ── */}
|
||||||
|
<ApprovalSection columns={["담당", "검토", "승인", "대표"]} />
|
||||||
|
|
||||||
|
{/* ── 푸터 ── */}
|
||||||
|
<div className="text-xs text-[#64748B] border-t-2 border-[#1E3A5F] pt-3">
|
||||||
|
<div className="flex justify-between mb-1">
|
||||||
|
<div>본 견적서의 유효기간은 견적일로부터 7일입니다.</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div>결제계좌: (예금주: )</div>
|
||||||
|
<div>문의: TEL 000-0000-0000 / FAX 000-0000-0000</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { cn } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
@@ -66,6 +66,10 @@ export interface DataGridProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onColumnOrderChange?: (columns: DataGridColumn[]) => void;
|
onColumnOrderChange?: (columns: DataGridColumn[]) => void;
|
||||||
gridId?: string;
|
gridId?: string;
|
||||||
|
/** 페이지네이션 표시 여부 (기본: true) */
|
||||||
|
showPagination?: boolean;
|
||||||
|
/** 초기 페이지 크기 (기본: 50) */
|
||||||
|
defaultPageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtNum = (val: any) => {
|
const fmtNum = (val: any) => {
|
||||||
@@ -217,6 +221,8 @@ export function DataGrid({
|
|||||||
loading = false,
|
loading = false,
|
||||||
onColumnOrderChange,
|
onColumnOrderChange,
|
||||||
gridId,
|
gridId,
|
||||||
|
showPagination = true,
|
||||||
|
defaultPageSize = 50,
|
||||||
}: DataGridProps) {
|
}: DataGridProps) {
|
||||||
const [columns, setColumns] = useState(initialColumns);
|
const [columns, setColumns] = useState(initialColumns);
|
||||||
useEffect(() => { setColumns(initialColumns); }, [initialColumns]);
|
useEffect(() => { setColumns(initialColumns); }, [initialColumns]);
|
||||||
@@ -228,6 +234,11 @@ export function DataGrid({
|
|||||||
// 헤더 필터 (컬럼별 선택된 값 Set)
|
// 헤더 필터 (컬럼별 선택된 값 Set)
|
||||||
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
|
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
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);
|
const [editingCell, setEditingCell] = useState<{ rowIdx: number; colKey: string } | null>(null);
|
||||||
// 이미지 확대 모달
|
// 이미지 확대 모달
|
||||||
@@ -340,6 +351,53 @@ export function DataGrid({
|
|||||||
return result;
|
return result;
|
||||||
}, [data, headerFilters, sortKey, sortDir]);
|
}, [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 startEdit = (rowIdx: number, colKey: string, currentVal: any) => {
|
||||||
const col = columns.find((c) => c.key === colKey);
|
const col = columns.find((c) => c.key === colKey);
|
||||||
@@ -351,7 +409,7 @@ export function DataGrid({
|
|||||||
const saveEdit = useCallback(async () => {
|
const saveEdit = useCallback(async () => {
|
||||||
if (!editingCell) return;
|
if (!editingCell) return;
|
||||||
const { rowIdx, colKey } = editingCell;
|
const { rowIdx, colKey } = editingCell;
|
||||||
const row = processedData[rowIdx];
|
const row = paginatedData[rowIdx];
|
||||||
if (!row) { setEditingCell(null); return; }
|
if (!row) { setEditingCell(null); return; }
|
||||||
|
|
||||||
const originalVal = String(row[colKey] ?? "");
|
const originalVal = String(row[colKey] ?? "");
|
||||||
@@ -374,7 +432,7 @@ export function DataGrid({
|
|||||||
|
|
||||||
onCellEdit?.(row.id, colKey, editValue, row);
|
onCellEdit?.(row.id, colKey, editValue, row);
|
||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
}, [editingCell, editValue, processedData, tableName, onCellEdit]);
|
}, [editingCell, editValue, paginatedData, tableName, onCellEdit]);
|
||||||
|
|
||||||
const cancelEdit = () => setEditingCell(null);
|
const cancelEdit = () => setEditingCell(null);
|
||||||
|
|
||||||
@@ -441,7 +499,8 @@ export function DataGrid({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-auto">
|
<div className="flex flex-col h-full flex-1 min-h-0">
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||||||
@@ -481,13 +540,13 @@ export function DataGrid({
|
|||||||
로딩 중...
|
로딩 중...
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : processedData.length === 0 ? (
|
) : paginatedData.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : processedData.map((row, rowIdx) => (
|
) : paginatedData.map((row, rowIdx) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.id || rowIdx}
|
key={row.id || rowIdx}
|
||||||
className={cn("cursor-pointer",
|
className={cn("cursor-pointer",
|
||||||
@@ -520,7 +579,7 @@ export function DataGrid({
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{showRowNumber && !showCheckbox && <TableCell className="text-center text-[10px] text-muted-foreground">{rowIdx + 1}</TableCell>}
|
{showRowNumber && !showCheckbox && <TableCell className="text-center text-[10px] text-muted-foreground">{pageOffset + rowIdx + 1}</TableCell>}
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={col.key}
|
key={col.key}
|
||||||
@@ -540,6 +599,77 @@ export function DataGrid({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 footer */}
|
||||||
|
{showPagination && (
|
||||||
|
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground shrink-0">
|
||||||
|
{/* 좌측: 데이터 수량 + 페이지 크기 입력 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>전체</span>
|
||||||
|
<span className="font-medium text-foreground">{totalItems.toLocaleString()}</span>
|
||||||
|
<span>건</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={pageSizeInput}
|
||||||
|
onChange={(e) => setPageSizeInput(e.target.value)}
|
||||||
|
onBlur={applyPageSize}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }}
|
||||||
|
className="h-7 w-16 text-center text-xs"
|
||||||
|
/>
|
||||||
|
<span>건씩 보기</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중앙: 페이지 이동 버튼 */}
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button onClick={() => setCurrentPage(1)} disabled={safePage === 1}
|
||||||
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed" title="첫 페이지">
|
||||||
|
<ChevronsLeft className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={safePage === 1}
|
||||||
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed" title="이전 페이지">
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
{getPageNumbers().map((page, idx) =>
|
||||||
|
page === "..." ? (
|
||||||
|
<span key={`dot-${idx}`} className="h-7 w-7 flex items-center justify-center text-muted-foreground">...</span>
|
||||||
|
) : (
|
||||||
|
<button key={page} onClick={() => setCurrentPage(page as number)}
|
||||||
|
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||||
|
page === safePage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={safePage === totalPages}
|
||||||
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed" title="다음 페이지">
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setCurrentPage(totalPages)} disabled={safePage === totalPages}
|
||||||
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed" title="마지막 페이지">
|
||||||
|
<ChevronsRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 좌측과 균형용 빈 공간 */}
|
||||||
|
<div className="flex items-center gap-3 invisible">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>전체</span>
|
||||||
|
<span>{totalItems.toLocaleString()}</span>
|
||||||
|
<span>건</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="h-7 w-16" />
|
||||||
|
<span>건씩 보기</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 이미지 확대 모달 */}
|
{/* 이미지 확대 모달 */}
|
||||||
{previewImage && (
|
{previewImage && (
|
||||||
|
|||||||
@@ -175,6 +175,13 @@ export function DynamicSearchFilter({
|
|||||||
width: f.width,
|
width: f.width,
|
||||||
}));
|
}));
|
||||||
setActiveFilters(active);
|
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]);
|
}, [externalFilterConfig]);
|
||||||
|
|
||||||
// select 타입 필터의 옵션 로드
|
// select 타입 필터의 옵션 로드
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<AlertDialog open={guard.showDialog} onOpenChange={(open) => !open && guard.cancelClose()}>
|
||||||
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[420px]">
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="text-base sm:text-lg">
|
||||||
|
{guard.title}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||||
|
{guard.description}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
|
취소
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={guard.confirmClose}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -361,7 +361,7 @@ function DynamicAdminLoader({ url, params }: { url: string; params?: Record<stri
|
|||||||
|
|
||||||
if (failed) return <AdminPageFallback url={url} />;
|
if (failed) return <AdminPageFallback url={url} />;
|
||||||
if (!Component) return <LoadingFallback />;
|
if (!Component) return <LoadingFallback />;
|
||||||
if (params) return <Component params={Promise.resolve(params)} />;
|
if (params) return <Component params={Promise.resolve(params)} adminParams={params} />;
|
||||||
return <Component />;
|
return <Component />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user