feat: 리포트 타입 에러 수정
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 자동 감지
|
||||
- 속성 패널
|
||||
- 프리뷰 모달
|
||||
|
||||
### 공통
|
||||
- 스크롤 정상
|
||||
- 콘솔 에러 없음
|
||||
@@ -1,15 +1,14 @@
|
||||
/**
|
||||
* 리포트 관리 컨트롤러
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { Response, NextFunction } from "express";
|
||||
import reportService from "../services/reportService";
|
||||
import {
|
||||
CreateReportRequest,
|
||||
UpdateReportRequest,
|
||||
SaveLayoutRequest,
|
||||
CreateTemplateRequest,
|
||||
GetReportsParams,
|
||||
} from "../types/report";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import {
|
||||
@@ -35,92 +34,91 @@ import {
|
||||
import { WatermarkConfig } from "../types/report";
|
||||
import bwipjs from "bwip-js";
|
||||
|
||||
function getUserInfo(req: AuthenticatedRequest) {
|
||||
return {
|
||||
userId: req.user?.userId || "SYSTEM",
|
||||
companyCode: req.user?.companyCode || "*",
|
||||
};
|
||||
}
|
||||
|
||||
export class ReportController {
|
||||
/**
|
||||
* 리포트 목록 조회
|
||||
* GET /api/admin/reports
|
||||
*/
|
||||
async getReports(req: Request, res: Response, next: NextFunction) {
|
||||
async getReports(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { companyCode } = getUserInfo(req);
|
||||
const {
|
||||
page = "1",
|
||||
limit = "20",
|
||||
searchText = "",
|
||||
reportType = "",
|
||||
useYn = "Y",
|
||||
sortBy = "created_at",
|
||||
sortOrder = "DESC",
|
||||
page = "1", limit = "20", searchText = "", searchField,
|
||||
startDate, endDate, reportType = "", useYn = "Y",
|
||||
sortBy = "created_at", sortOrder = "DESC",
|
||||
} = req.query;
|
||||
|
||||
const result = await reportService.getReports({
|
||||
page: parseInt(page as string, 10),
|
||||
limit: parseInt(limit as string, 10),
|
||||
searchText: searchText as string,
|
||||
searchField: searchField as GetReportsParams["searchField"],
|
||||
startDate: startDate as string | undefined,
|
||||
endDate: endDate as string | undefined,
|
||||
reportType: reportType as string,
|
||||
useYn: useYn as string,
|
||||
sortBy: sortBy as string,
|
||||
sortOrder: sortOrder as "ASC" | "DESC",
|
||||
});
|
||||
}, companyCode);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 상세 조회
|
||||
* GET /api/admin/reports/:reportId
|
||||
*/
|
||||
async getReportById(req: Request, res: Response, next: NextFunction) {
|
||||
async getReportById(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { companyCode } = getUserInfo(req);
|
||||
const { reportId } = req.params;
|
||||
|
||||
const report = await reportService.getReportById(reportId);
|
||||
const report = await reportService.getReportById(reportId, companyCode);
|
||||
|
||||
if (!report) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "리포트를 찾을 수 없습니다.",
|
||||
});
|
||||
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: report,
|
||||
});
|
||||
return res.json({ success: true, data: report });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 생성
|
||||
* POST /api/admin/reports
|
||||
*/
|
||||
async createReport(req: Request, res: Response, next: NextFunction) {
|
||||
async getReportsByMenuObjid(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data: CreateReportRequest = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
const { companyCode } = getUserInfo(req);
|
||||
const { menuObjid } = req.params;
|
||||
const menuObjidNum = parseInt(menuObjid, 10);
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!data.reportNameKor || !data.reportType) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "리포트명과 리포트 타입은 필수입니다.",
|
||||
});
|
||||
if (isNaN(menuObjidNum)) {
|
||||
return res.status(400).json({ success: false, message: "menuObjid는 숫자여야 합니다." });
|
||||
}
|
||||
|
||||
const result = await reportService.getReportsByMenuObjid(menuObjidNum, companyCode);
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { userId, companyCode } = getUserInfo(req);
|
||||
const data: CreateReportRequest = req.body;
|
||||
|
||||
if (!data.reportNameKor || !data.reportType) {
|
||||
return res.status(400).json({ success: false, message: "리포트명과 리포트 타입은 필수입니다." });
|
||||
}
|
||||
|
||||
data.companyCode = companyCode;
|
||||
const reportId = await reportService.createReport(data, userId);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
reportId,
|
||||
},
|
||||
data: { reportId },
|
||||
message: "리포트가 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -128,83 +126,56 @@ export class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 수정
|
||||
* PUT /api/admin/reports/:reportId
|
||||
*/
|
||||
async updateReport(req: Request, res: Response, next: NextFunction) {
|
||||
async updateReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { userId, companyCode } = getUserInfo(req);
|
||||
const { reportId } = req.params;
|
||||
const data: UpdateReportRequest = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
const success = await reportService.updateReport(reportId, data, userId);
|
||||
const success = await reportService.updateReport(reportId, data, userId, companyCode);
|
||||
|
||||
if (!success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "수정할 내용이 없습니다.",
|
||||
});
|
||||
return res.status(400).json({ success: false, message: "수정할 내용이 없습니다." });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "리포트가 수정되었습니다.",
|
||||
});
|
||||
return res.json({ success: true, message: "리포트가 수정되었습니다." });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 삭제
|
||||
* DELETE /api/admin/reports/:reportId
|
||||
*/
|
||||
async deleteReport(req: Request, res: Response, next: NextFunction) {
|
||||
async deleteReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { companyCode } = getUserInfo(req);
|
||||
const { reportId } = req.params;
|
||||
|
||||
const success = await reportService.deleteReport(reportId);
|
||||
const success = await reportService.deleteReport(reportId, companyCode);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "리포트를 찾을 수 없습니다.",
|
||||
});
|
||||
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "리포트가 삭제되었습니다.",
|
||||
});
|
||||
return res.json({ success: true, message: "리포트가 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 복사
|
||||
* POST /api/admin/reports/:reportId/copy
|
||||
*/
|
||||
async copyReport(req: Request, res: Response, next: NextFunction) {
|
||||
async copyReport(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { userId, companyCode } = getUserInfo(req);
|
||||
const { reportId } = req.params;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
const { newName } = req.body;
|
||||
|
||||
const newReportId = await reportService.copyReport(reportId, userId);
|
||||
const newReportId = await reportService.copyReport(reportId, userId, companyCode, newName);
|
||||
|
||||
if (!newReportId) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "리포트를 찾을 수 없습니다.",
|
||||
});
|
||||
return res.status(404).json({ success: false, message: "리포트를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
reportId: newReportId,
|
||||
},
|
||||
data: { reportId: newReportId },
|
||||
message: "리포트가 복사되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -212,132 +183,92 @@ export class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 조회
|
||||
* GET /api/admin/reports/:reportId/layout
|
||||
*/
|
||||
async getLayout(req: Request, res: Response, next: NextFunction) {
|
||||
async getLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { companyCode } = getUserInfo(req);
|
||||
const { reportId } = req.params;
|
||||
|
||||
const layout = await reportService.getLayout(reportId);
|
||||
const layout = await reportService.getLayout(reportId, companyCode);
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
return res.status(404).json({ success: false, message: "레이아웃을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
// components 컬럼에서 JSON 파싱
|
||||
const parsedComponents = layout.components
|
||||
? JSON.parse(layout.components)
|
||||
: null;
|
||||
|
||||
const storedData = layout.components;
|
||||
let layoutData;
|
||||
// 새 구조 (layoutConfig.pages)인지 확인
|
||||
|
||||
if (
|
||||
parsedComponents &&
|
||||
parsedComponents.pages &&
|
||||
Array.isArray(parsedComponents.pages)
|
||||
storedData &&
|
||||
typeof storedData === "object" &&
|
||||
!Array.isArray(storedData) &&
|
||||
Array.isArray((storedData as Record<string, unknown>).pages)
|
||||
) {
|
||||
// pages 배열을 직접 포함하여 반환
|
||||
const parsed = storedData as Record<string, unknown>;
|
||||
layoutData = {
|
||||
...layout,
|
||||
pages: parsedComponents.pages,
|
||||
components: [], // 호환성을 위해 빈 배열
|
||||
pages: parsed.pages,
|
||||
watermark: parsed.watermark,
|
||||
components: storedData,
|
||||
};
|
||||
} else {
|
||||
// 기존 구조: components 배열
|
||||
layoutData = {
|
||||
...layout,
|
||||
components: parsedComponents || [],
|
||||
};
|
||||
layoutData = { ...layout, components: storedData || [] };
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: layoutData,
|
||||
});
|
||||
return res.json({ success: true, data: layoutData });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 저장
|
||||
* PUT /api/admin/reports/:reportId/layout
|
||||
*/
|
||||
async saveLayout(req: Request, res: Response, next: NextFunction) {
|
||||
async saveLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { userId, companyCode } = getUserInfo(req);
|
||||
const { reportId } = req.params;
|
||||
const data: SaveLayoutRequest = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
// 필수 필드 검증 (페이지 기반 구조)
|
||||
if (
|
||||
!data.layoutConfig ||
|
||||
!data.layoutConfig.pages ||
|
||||
data.layoutConfig.pages.length === 0
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "레이아웃 설정이 필요합니다.",
|
||||
});
|
||||
if (!data.layoutConfig?.pages?.length) {
|
||||
return res.status(400).json({ success: false, message: "레이아웃 설정이 필요합니다." });
|
||||
}
|
||||
|
||||
await reportService.saveLayout(reportId, data, userId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "레이아웃이 저장되었습니다.",
|
||||
});
|
||||
await reportService.saveLayout(reportId, data, userId, companyCode);
|
||||
return res.json({ success: true, message: "레이아웃이 저장되었습니다." });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 목록 조회
|
||||
* GET /api/admin/reports/templates
|
||||
*/
|
||||
async getTemplates(req: Request, res: Response, next: NextFunction) {
|
||||
async getTemplates(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const templates = await reportService.getTemplates();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: templates,
|
||||
});
|
||||
return res.json({ success: true, data: templates });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 생성
|
||||
* POST /api/admin/reports/templates
|
||||
*/
|
||||
async createTemplate(req: Request, res: Response, next: NextFunction) {
|
||||
async getCategories(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data: CreateTemplateRequest = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
const categories = await reportService.getCategories();
|
||||
return res.json({ success: true, data: categories });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { userId } = getUserInfo(req);
|
||||
const data: CreateTemplateRequest = req.body;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!data.templateNameKor || !data.templateType) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "템플릿명과 템플릿 타입은 필수입니다.",
|
||||
});
|
||||
return res.status(400).json({ success: false, message: "템플릿명과 템플릿 타입은 필수입니다." });
|
||||
}
|
||||
|
||||
const templateId = await reportService.createTemplate(data, userId);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
templateId,
|
||||
},
|
||||
data: { templateId },
|
||||
message: "템플릿이 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -345,37 +276,23 @@ export class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 리포트를 템플릿으로 저장
|
||||
* POST /api/admin/reports/:reportId/save-as-template
|
||||
*/
|
||||
async saveAsTemplate(req: Request, res: Response, next: NextFunction) {
|
||||
async saveAsTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { userId } = getUserInfo(req);
|
||||
const { reportId } = req.params;
|
||||
const { templateNameKor, templateNameEng, description } = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!templateNameKor) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "템플릿명은 필수입니다.",
|
||||
});
|
||||
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
|
||||
}
|
||||
|
||||
const templateId = await reportService.saveAsTemplate(
|
||||
reportId,
|
||||
templateNameKor,
|
||||
templateNameEng,
|
||||
description,
|
||||
userId
|
||||
reportId, templateNameKor, templateNameEng, description, userId
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
templateId,
|
||||
},
|
||||
data: { templateId },
|
||||
message: "템플릿이 저장되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -383,39 +300,20 @@ export class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
|
||||
* POST /api/admin/reports/templates/create-from-layout
|
||||
*/
|
||||
async createTemplateFromLayout(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
async createTemplateFromLayout(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { userId } = getUserInfo(req);
|
||||
const {
|
||||
templateNameKor,
|
||||
templateNameEng,
|
||||
templateType,
|
||||
description,
|
||||
layoutConfig,
|
||||
defaultQueries = [],
|
||||
templateNameKor, templateNameEng, templateType,
|
||||
description, layoutConfig, defaultQueries = [],
|
||||
} = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!templateNameKor) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "템플릿명은 필수입니다.",
|
||||
});
|
||||
return res.status(400).json({ success: false, message: "템플릿명은 필수입니다." });
|
||||
}
|
||||
|
||||
if (!layoutConfig) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "레이아웃 설정은 필수입니다.",
|
||||
});
|
||||
return res.status(400).json({ success: false, message: "레이아웃 설정은 필수입니다." });
|
||||
}
|
||||
|
||||
const templateId = await reportService.createTemplateFromLayout(
|
||||
@@ -440,78 +338,47 @@ export class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 삭제
|
||||
* DELETE /api/admin/reports/templates/:templateId
|
||||
*/
|
||||
async deleteTemplate(req: Request, res: Response, next: NextFunction) {
|
||||
async deleteTemplate(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { templateId } = req.params;
|
||||
|
||||
const success = await reportService.deleteTemplate(templateId);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다.",
|
||||
});
|
||||
return res.status(404).json({ success: false, message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다." });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "템플릿이 삭제되었습니다.",
|
||||
});
|
||||
return res.json({ success: true, message: "템플릿이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 실행
|
||||
* POST /api/admin/reports/:reportId/queries/:queryId/execute
|
||||
*/
|
||||
async executeQuery(req: Request, res: Response, next: NextFunction) {
|
||||
async executeQuery(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { reportId, queryId } = req.params;
|
||||
const { parameters = {}, sqlQuery, externalConnectionId } = req.body;
|
||||
|
||||
const result = await reportService.executeQuery(
|
||||
reportId,
|
||||
queryId,
|
||||
parameters,
|
||||
sqlQuery,
|
||||
externalConnectionId
|
||||
reportId, queryId, parameters, sqlQuery, externalConnectionId
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: error.message || "쿼리 실행에 실패했습니다.",
|
||||
});
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다.";
|
||||
return res.status(400).json({ success: false, message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 연결 목록 조회 (활성화된 것만)
|
||||
* GET /api/admin/reports/external-connections
|
||||
*/
|
||||
async getExternalConnections(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
async getExternalConnections(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { companyCode } = getUserInfo(req);
|
||||
const { ExternalDbConnectionService } = await import(
|
||||
"../services/externalDbConnectionService"
|
||||
);
|
||||
|
||||
const result = await ExternalDbConnectionService.getConnections({
|
||||
is_active: "Y",
|
||||
company_code: req.body.companyCode || "",
|
||||
company_code: companyCode,
|
||||
});
|
||||
|
||||
return res.json(result);
|
||||
@@ -520,52 +387,34 @@ export class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 파일 업로드
|
||||
* POST /api/admin/reports/upload-image
|
||||
*/
|
||||
async uploadImage(req: Request, res: Response, next: NextFunction) {
|
||||
async uploadImage(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "이미지 파일이 필요합니다.",
|
||||
});
|
||||
return res.status(400).json({ success: false, message: "이미지 파일이 필요합니다." });
|
||||
}
|
||||
|
||||
const companyCode = req.body.companyCode || "SYSTEM";
|
||||
const { companyCode } = getUserInfo(req);
|
||||
const file = req.file;
|
||||
|
||||
// 파일 저장 경로 생성
|
||||
const uploadDir = path.join(
|
||||
process.cwd(),
|
||||
"uploads",
|
||||
`company_${companyCode}`,
|
||||
"reports"
|
||||
);
|
||||
const uploadDir = path.join(process.cwd(), "uploads", `company_${companyCode}`, "reports");
|
||||
|
||||
// 디렉토리가 없으면 생성
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 고유한 파일명 생성 (타임스탬프 + 원본 파일명)
|
||||
const timestamp = Date.now();
|
||||
const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const fileName = `${timestamp}_${safeFileName}`;
|
||||
const filePath = path.join(uploadDir, fileName);
|
||||
|
||||
// 파일 저장
|
||||
fs.writeFileSync(filePath, file.buffer);
|
||||
|
||||
// 웹에서 접근 가능한 URL 반환
|
||||
const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
fileName,
|
||||
fileUrl,
|
||||
fileName, fileUrl,
|
||||
originalName: file.originalname,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
@@ -576,11 +425,7 @@ export class ReportController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 데이터를 WORD(DOCX)로 변환
|
||||
* POST /api/admin/reports/export-word
|
||||
*/
|
||||
async exportToWord(req: Request, res: Response, next: NextFunction) {
|
||||
async exportToWord(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { layoutConfig, queryResults, fileName = "리포트" } = req.body;
|
||||
|
||||
@@ -591,22 +436,15 @@ export class ReportController {
|
||||
});
|
||||
}
|
||||
|
||||
// mm를 twip으로 변환
|
||||
const mmToTwip = (mm: number) => convertMillimetersToTwip(mm);
|
||||
|
||||
// 프론트엔드와 동일한 MM_TO_PX 상수 (캔버스에서 mm를 px로 변환할 때 사용하는 값)
|
||||
const MM_TO_PX = 4;
|
||||
// 1mm = 56.692913386 twip (docx 라이브러리 기준)
|
||||
// px를 twip으로 변환: px -> mm -> twip
|
||||
const MM_TO_PX = 4; // 프론트엔드와 동일, 1mm = 56.692913386 twip (docx)
|
||||
const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386);
|
||||
|
||||
// 쿼리 결과 맵
|
||||
const queryResultsMap: Record<
|
||||
string,
|
||||
{ fields: string[]; rows: Record<string, unknown>[] }
|
||||
> = queryResults || {};
|
||||
|
||||
// 컴포넌트 값 가져오기
|
||||
const getComponentValue = (component: any): string => {
|
||||
if (component.queryId && component.fieldName) {
|
||||
const queryResult = queryResultsMap[component.queryId];
|
||||
@@ -621,11 +459,9 @@ export class ReportController {
|
||||
return component.defaultValue || "";
|
||||
};
|
||||
|
||||
// px → half-point 변환 (1px = 0.75pt, Word는 half-pt 단위 사용)
|
||||
// px * 0.75 * 2 = px * 1.5
|
||||
// px → half-point (1px = 0.75pt, px * 1.5)
|
||||
const pxToHalfPt = (px: number) => Math.round(px * 1.5);
|
||||
|
||||
// 셀 내용 생성 헬퍼 함수 (가로 배치용)
|
||||
const createCellContent = (
|
||||
component: any,
|
||||
displayValue: string,
|
||||
@@ -1557,7 +1393,7 @@ export class ReportController {
|
||||
const base64 = png.toString("base64");
|
||||
return `data:image/png;base64,${base64}`;
|
||||
} catch (error) {
|
||||
console.error("바코드 생성 오류:", error);
|
||||
logger.error("바코드 생성 오류:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1891,7 +1727,7 @@ export class ReportController {
|
||||
children.push(paragraph);
|
||||
lastBottomY = adjustedY + component.height;
|
||||
} catch (imgError) {
|
||||
console.error("이미지 처리 오류:", imgError);
|
||||
logger.error("이미지 처리 오류:", imgError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2005,7 +1841,7 @@ export class ReportController {
|
||||
});
|
||||
children.push(paragraph);
|
||||
} catch (imgError) {
|
||||
console.error("서명 이미지 오류:", imgError);
|
||||
logger.error("서명 이미지 오류:", imgError);
|
||||
textRuns.push(
|
||||
new TextRun({
|
||||
text: "_".repeat(20),
|
||||
@@ -2083,7 +1919,7 @@ export class ReportController {
|
||||
});
|
||||
children.push(paragraph);
|
||||
} catch (imgError) {
|
||||
console.error("도장 이미지 오류:", imgError);
|
||||
logger.error("도장 이미지 오류:", imgError);
|
||||
textRuns.push(
|
||||
new TextRun({
|
||||
text: "(인)",
|
||||
@@ -2886,7 +2722,7 @@ export class ReportController {
|
||||
})
|
||||
);
|
||||
} catch (imgError) {
|
||||
console.error("바코드 이미지 오류:", imgError);
|
||||
logger.error("바코드 이미지 오류:", imgError);
|
||||
// 바코드 이미지 생성 실패 시 텍스트로 대체
|
||||
const barcodeValue = component.barcodeValue || "BARCODE";
|
||||
children.push(
|
||||
@@ -3164,13 +3000,57 @@ export class ReportController {
|
||||
|
||||
return res.send(docxBuffer);
|
||||
} catch (error: any) {
|
||||
console.error("WORD 변환 오류:", error);
|
||||
logger.error("WORD 변환 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "WORD 변환에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 비주얼 쿼리 빌더 API ─────────────────────────────────────────────────────
|
||||
|
||||
async getSchemaTables(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const tables = await reportService.getSchemaTables();
|
||||
return res.json({ success: true, data: tables });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "테이블 목록 조회에 실패했습니다.";
|
||||
logger.error("스키마 테이블 조회 오류:", { error: message });
|
||||
return res.status(500).json({ success: false, message });
|
||||
}
|
||||
}
|
||||
|
||||
async getSchemaTableColumns(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
if (!tableName) {
|
||||
return res.status(400).json({ success: false, message: "테이블명이 필요합니다." });
|
||||
}
|
||||
const columns = await reportService.getSchemaTableColumns(tableName);
|
||||
return res.json({ success: true, data: columns });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "컬럼 목록 조회에 실패했습니다.";
|
||||
logger.error("테이블 컬럼 조회 오류:", { error: message });
|
||||
return res.status(500).json({ success: false, message });
|
||||
}
|
||||
}
|
||||
|
||||
async previewVisualQuery(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { visualQuery } = req.body;
|
||||
if (!visualQuery || !visualQuery.tableName) {
|
||||
return res.status(400).json({ success: false, message: "visualQuery 정보가 필요합니다." });
|
||||
}
|
||||
const result = await reportService.executeVisualQuery(visualQuery);
|
||||
const generatedSql = reportService.buildVisualQuerySql(visualQuery);
|
||||
return res.json({ success: true, data: { ...result, sql: generatedSql } });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : "쿼리 실행에 실패했습니다.";
|
||||
logger.error("비주얼 쿼리 미리보기 오류:", { error: message });
|
||||
return res.status(500).json({ success: false, message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReportController();
|
||||
|
||||
@@ -43,6 +43,11 @@ router.get("/templates", (req, res, next) =>
|
||||
router.post("/templates", (req, res, next) =>
|
||||
reportController.createTemplate(req, res, next)
|
||||
);
|
||||
|
||||
// 카테고리(report_type) 목록 조회
|
||||
router.get("/categories", (req, res, next) =>
|
||||
reportController.getCategories(req, res, next)
|
||||
);
|
||||
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
|
||||
router.post("/templates/create-from-layout", (req, res, next) =>
|
||||
reportController.createTemplateFromLayout(req, res, next)
|
||||
@@ -61,6 +66,17 @@ router.post("/export-word", (req, res, next) =>
|
||||
reportController.exportToWord(req, res, next)
|
||||
);
|
||||
|
||||
// 비주얼 쿼리 빌더 — 스키마 조회 (/:reportId 패턴보다 반드시 먼저 등록)
|
||||
router.get("/schema/tables", (req, res, next) =>
|
||||
reportController.getSchemaTables(req, res, next)
|
||||
);
|
||||
router.get("/schema/tables/:tableName/columns", (req, res, next) =>
|
||||
reportController.getSchemaTableColumns(req, res, next)
|
||||
);
|
||||
router.post("/schema/preview", (req, res, next) =>
|
||||
reportController.previewVisualQuery(req, res, next)
|
||||
);
|
||||
|
||||
// 리포트 목록
|
||||
router.get("/", (req, res, next) =>
|
||||
reportController.getReports(req, res, next)
|
||||
@@ -71,6 +87,11 @@ router.post("/", (req, res, next) =>
|
||||
reportController.createReport(req, res, next)
|
||||
);
|
||||
|
||||
// 메뉴별 리포트 목록 (/:reportId 보다 반드시 먼저 등록)
|
||||
router.get("/by-menu/:menuObjid", (req, res, next) =>
|
||||
reportController.getReportsByMenuObjid(req, res, next)
|
||||
);
|
||||
|
||||
// 리포트 복사 (구체적인 경로를 먼저 배치)
|
||||
router.post("/:reportId/copy", (req, res, next) =>
|
||||
reportController.copyReport(req, res, next)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* 리포트 관리 시스템 타입 정의
|
||||
*/
|
||||
|
||||
// 리포트 템플릿
|
||||
export interface ReportTemplate {
|
||||
template_id: string;
|
||||
template_name_kor: string;
|
||||
@@ -21,12 +16,12 @@ export interface ReportTemplate {
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
// 리포트 마스터
|
||||
export interface ReportMaster {
|
||||
report_id: string;
|
||||
report_name_kor: string;
|
||||
report_name_eng: string | null;
|
||||
template_id: string | null;
|
||||
template_name: string | null;
|
||||
report_type: string;
|
||||
company_code: string | null;
|
||||
description: string | null;
|
||||
@@ -37,7 +32,6 @@ export interface ReportMaster {
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
// 리포트 레이아웃
|
||||
export interface ReportLayout {
|
||||
layout_id: string;
|
||||
report_id: string;
|
||||
@@ -55,7 +49,6 @@ export interface ReportLayout {
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
// 리포트 쿼리
|
||||
export interface ReportQuery {
|
||||
query_id: string;
|
||||
report_id: string;
|
||||
@@ -63,7 +56,7 @@ export interface ReportQuery {
|
||||
query_type: "MASTER" | "DETAIL";
|
||||
sql_query: string;
|
||||
parameters: string[] | null;
|
||||
external_connection_id: number | null; // 외부 DB 연결 ID (NULL이면 내부 DB)
|
||||
external_connection_id: number | null;
|
||||
display_order: number;
|
||||
created_at: Date;
|
||||
created_by: string | null;
|
||||
@@ -71,34 +64,37 @@ export interface ReportQuery {
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
// 리포트 상세 (마스터 + 레이아웃 + 쿼리 + 연결된 메뉴)
|
||||
export interface ReportDetail {
|
||||
report: ReportMaster;
|
||||
layout: ReportLayout | null;
|
||||
queries: ReportQuery[];
|
||||
menuObjids?: number[]; // 연결된 메뉴 ID 목록
|
||||
menuObjids?: number[];
|
||||
}
|
||||
|
||||
// 리포트 목록 조회 파라미터
|
||||
export interface GetReportsParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
searchText?: string;
|
||||
searchField?: "report_name" | "created_by" | "report_type" | "updated_at" | "created_at";
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
reportType?: string;
|
||||
useYn?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
// 리포트 목록 응답
|
||||
export interface GetReportsResponse {
|
||||
items: ReportMaster[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
typeSummary: Array<{ type: string; count: number }>;
|
||||
allTypes: string[];
|
||||
recentActivity: Array<{ date: string; count: number }>;
|
||||
recentTotal: number;
|
||||
}
|
||||
|
||||
// 리포트 생성 요청
|
||||
export interface CreateReportRequest {
|
||||
reportNameKor: string;
|
||||
reportNameEng?: string;
|
||||
@@ -108,7 +104,6 @@ export interface CreateReportRequest {
|
||||
companyCode?: string;
|
||||
}
|
||||
|
||||
// 리포트 수정 요청
|
||||
export interface UpdateReportRequest {
|
||||
reportNameKor?: string;
|
||||
reportNameEng?: string;
|
||||
@@ -117,23 +112,18 @@ export interface UpdateReportRequest {
|
||||
useYn?: string;
|
||||
}
|
||||
|
||||
// 워터마크 설정
|
||||
export interface WatermarkConfig {
|
||||
enabled: boolean;
|
||||
type: "text" | "image";
|
||||
// 텍스트 워터마크
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
// 이미지 워터마크
|
||||
imageUrl?: string;
|
||||
// 공통 설정
|
||||
opacity: number; // 0~1
|
||||
opacity: number;
|
||||
style: "diagonal" | "center" | "tile";
|
||||
rotation?: number; // 대각선일 때 각도 (기본 -45)
|
||||
rotation?: number;
|
||||
}
|
||||
|
||||
// 페이지 설정
|
||||
export interface PageConfig {
|
||||
page_id: string;
|
||||
page_name: string;
|
||||
@@ -147,30 +137,29 @@ export interface PageConfig {
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
components: any[];
|
||||
components: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
// 레이아웃 설정
|
||||
export interface ReportLayoutConfig {
|
||||
pages: PageConfig[];
|
||||
watermark?: WatermarkConfig; // 전체 페이지 공유 워터마크
|
||||
watermark?: WatermarkConfig;
|
||||
}
|
||||
|
||||
// 레이아웃 저장 요청
|
||||
export interface SaveLayoutRequest {
|
||||
layoutConfig: ReportLayoutConfig;
|
||||
queries?: Array<{
|
||||
export interface SaveLayoutQueryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "MASTER" | "DETAIL";
|
||||
sqlQuery: string;
|
||||
parameters: string[];
|
||||
externalConnectionId?: number;
|
||||
}>;
|
||||
menuObjids?: number[]; // 연결할 메뉴 ID 목록
|
||||
externalConnectionId?: number | null;
|
||||
}
|
||||
|
||||
export interface SaveLayoutRequest {
|
||||
layoutConfig: ReportLayoutConfig;
|
||||
queries?: SaveLayoutQueryItem[];
|
||||
menuObjids?: number[];
|
||||
}
|
||||
|
||||
// 리포트-메뉴 매핑
|
||||
export interface ReportMenuMapping {
|
||||
mapping_id: number;
|
||||
report_id: string;
|
||||
@@ -180,23 +169,20 @@ export interface ReportMenuMapping {
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
// 템플릿 목록 응답
|
||||
export interface GetTemplatesResponse {
|
||||
system: ReportTemplate[];
|
||||
custom: ReportTemplate[];
|
||||
}
|
||||
|
||||
// 템플릿 생성 요청
|
||||
export interface CreateTemplateRequest {
|
||||
templateNameKor: string;
|
||||
templateNameEng?: string;
|
||||
templateType: string;
|
||||
description?: string;
|
||||
layoutConfig?: any;
|
||||
defaultQueries?: any;
|
||||
layoutConfig?: Record<string, unknown>;
|
||||
defaultQueries?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
// 컴포넌트 설정 (프론트엔드와 동기화)
|
||||
export interface ComponentConfig {
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -224,21 +210,16 @@ export interface ComponentConfig {
|
||||
conditional?: string;
|
||||
locked?: boolean;
|
||||
groupId?: string;
|
||||
// 이미지 전용
|
||||
imageUrl?: string;
|
||||
objectFit?: "contain" | "cover" | "fill" | "none";
|
||||
// 구분선 전용
|
||||
orientation?: "horizontal" | "vertical";
|
||||
lineStyle?: "solid" | "dashed" | "dotted" | "double";
|
||||
lineWidth?: number;
|
||||
lineColor?: string;
|
||||
// 서명/도장 전용
|
||||
showLabel?: boolean;
|
||||
labelText?: string;
|
||||
labelPosition?: "top" | "left" | "bottom" | "right";
|
||||
showUnderline?: boolean;
|
||||
personName?: string;
|
||||
// 테이블 전용
|
||||
tableColumns?: Array<{
|
||||
field: string;
|
||||
header: string;
|
||||
@@ -249,9 +230,7 @@ export interface ComponentConfig {
|
||||
headerTextColor?: string;
|
||||
showBorder?: boolean;
|
||||
rowHeight?: number;
|
||||
// 페이지 번호 전용
|
||||
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber";
|
||||
// 카드 컴포넌트 전용
|
||||
cardTitle?: string;
|
||||
cardItems?: Array<{
|
||||
label: string;
|
||||
@@ -267,7 +246,6 @@ export interface ComponentConfig {
|
||||
titleColor?: string;
|
||||
labelColor?: string;
|
||||
valueColor?: string;
|
||||
// 계산 컴포넌트 전용
|
||||
calcItems?: Array<{
|
||||
label: string;
|
||||
value: number | string;
|
||||
@@ -280,7 +258,6 @@ export interface ComponentConfig {
|
||||
showCalcBorder?: boolean;
|
||||
numberFormat?: "none" | "comma" | "currency";
|
||||
currencySuffix?: string;
|
||||
// 바코드 컴포넌트 전용
|
||||
barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
|
||||
barcodeValue?: string;
|
||||
barcodeFieldName?: string;
|
||||
@@ -289,19 +266,118 @@ export interface ComponentConfig {
|
||||
barcodeBackground?: string;
|
||||
barcodeMargin?: number;
|
||||
qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H";
|
||||
// QR코드 다중 필드 (JSON 형식)
|
||||
qrDataFields?: Array<{
|
||||
fieldName: string;
|
||||
label: string;
|
||||
}>;
|
||||
qrUseMultiField?: boolean;
|
||||
qrIncludeAllRows?: boolean;
|
||||
// 체크박스 컴포넌트 전용
|
||||
checkboxChecked?: boolean; // 체크 상태 (고정값)
|
||||
checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값)
|
||||
checkboxLabel?: string; // 체크박스 옆 레이블 텍스트
|
||||
checkboxSize?: number; // 체크박스 크기 (px)
|
||||
checkboxColor?: string; // 체크 색상
|
||||
checkboxBorderColor?: string; // 테두리 색상
|
||||
checkboxLabelPosition?: "left" | "right"; // 레이블 위치
|
||||
checkboxChecked?: boolean;
|
||||
checkboxFieldName?: string;
|
||||
checkboxLabel?: string;
|
||||
checkboxSize?: number;
|
||||
checkboxColor?: string;
|
||||
checkboxBorderColor?: string;
|
||||
checkboxLabelPosition?: "left" | "right";
|
||||
visualQuery?: VisualQuery;
|
||||
// 카드 레이아웃 설정 (card 컴포넌트 전용 - v3)
|
||||
cardLayoutConfig?: CardLayoutConfig;
|
||||
}
|
||||
|
||||
export interface VisualQueryFormulaColumn {
|
||||
alias: string;
|
||||
header: string;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export interface VisualQuery {
|
||||
tableName: string;
|
||||
limit?: number;
|
||||
columns: string[];
|
||||
formulaColumns: VisualQueryFormulaColumn[];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 카드 레이아웃 v3 타입 정의
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CardElementType = "header" | "dataCell" | "divider" | "badge";
|
||||
export type CellDirection = "vertical" | "horizontal";
|
||||
|
||||
export interface CardElementBase {
|
||||
id: string;
|
||||
type: CardElementType;
|
||||
colspan?: number;
|
||||
rowspan?: number;
|
||||
}
|
||||
|
||||
export interface CardHeaderElement extends CardElementBase {
|
||||
type: "header";
|
||||
icon?: string;
|
||||
iconColor?: string;
|
||||
title: string;
|
||||
titleColor?: string;
|
||||
titleFontSize?: number;
|
||||
}
|
||||
|
||||
export interface CardDataCellElement extends CardElementBase {
|
||||
type: "dataCell";
|
||||
direction: CellDirection;
|
||||
label: string;
|
||||
columnName?: string;
|
||||
inputType?: "text" | "date" | "number" | "select" | "readonly";
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
selectOptions?: string[];
|
||||
labelWidth?: number;
|
||||
labelFontSize?: number;
|
||||
labelColor?: string;
|
||||
valueFontSize?: number;
|
||||
valueColor?: string;
|
||||
}
|
||||
|
||||
export interface CardDividerElement extends CardElementBase {
|
||||
type: "divider";
|
||||
style?: "solid" | "dashed" | "dotted";
|
||||
color?: string;
|
||||
thickness?: number;
|
||||
}
|
||||
|
||||
export interface CardBadgeElement extends CardElementBase {
|
||||
type: "badge";
|
||||
label?: string;
|
||||
columnName?: string;
|
||||
colorMap?: Record<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;
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -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;
|
||||
```
|
||||
@@ -1,29 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { ReportDesignerToolbar } from "@/components/report/designer/ReportDesignerToolbar";
|
||||
import { PageListPanel } from "@/components/report/designer/PageListPanel";
|
||||
import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel";
|
||||
import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas";
|
||||
import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel";
|
||||
import { ReportDesignerProvider } from "@/contexts/ReportDesignerContext";
|
||||
import { ReportDesignerProvider, useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { ComponentSettingsModal } from "@/components/report/designer/modals/ComponentSettingsModal";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function ReportDesignerPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const reportId = params.reportId as string;
|
||||
const BREAKPOINT_COLLAPSE_LEFT = 1200;
|
||||
const BREAKPOINT_COLLAPSE_ALL = 900;
|
||||
|
||||
function DesignerLayout() {
|
||||
const {
|
||||
setIsPageListCollapsed,
|
||||
setIsLeftPanelCollapsed,
|
||||
setIsRightPanelCollapsed,
|
||||
} = useReportDesigner();
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
const w = window.innerWidth;
|
||||
if (w < BREAKPOINT_COLLAPSE_ALL) {
|
||||
setIsPageListCollapsed(true);
|
||||
setIsLeftPanelCollapsed(true);
|
||||
setIsRightPanelCollapsed(true);
|
||||
} else if (w < BREAKPOINT_COLLAPSE_LEFT) {
|
||||
setIsPageListCollapsed(true);
|
||||
setIsLeftPanelCollapsed(false);
|
||||
setIsRightPanelCollapsed(false);
|
||||
}
|
||||
}, [setIsPageListCollapsed, setIsLeftPanelCollapsed, setIsRightPanelCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [handleResize]);
|
||||
|
||||
return (
|
||||
<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 { toast } = useToast();
|
||||
const closeTab = useTabStore((s) => s.closeTab);
|
||||
const currentTabId = useTabId();
|
||||
|
||||
const closeDesignerTab = useCallback(() => {
|
||||
if (currentTabId) {
|
||||
closeTab(currentTabId);
|
||||
}
|
||||
}, [currentTabId, closeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadReport = async () => {
|
||||
// 'new'는 새 리포트 생성 모드
|
||||
if (reportId === "new") {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
@@ -37,7 +95,7 @@ export default function ReportDesignerPage() {
|
||||
description: "리포트를 찾을 수 없습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
router.push("/admin/screenMng/reportList");
|
||||
closeDesignerTab();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
@@ -45,7 +103,7 @@ export default function ReportDesignerPage() {
|
||||
description: error.message || "리포트를 불러오는데 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
router.push("/admin/screenMng/reportList");
|
||||
closeDesignerTab();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -54,7 +112,7 @@ export default function ReportDesignerPage() {
|
||||
if (reportId) {
|
||||
loadReport();
|
||||
}
|
||||
}, [reportId, router, toast]);
|
||||
}, [reportId, closeDesignerTab, toast]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -65,28 +123,12 @@ export default function ReportDesignerPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="report-designer-dnd-root">
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<ReportDesignerProvider reportId={reportId}>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-muted">
|
||||
{/* 상단 툴바 */}
|
||||
<ReportDesignerToolbar />
|
||||
|
||||
{/* 메인 영역 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 페이지 목록 패널 */}
|
||||
<PageListPanel />
|
||||
|
||||
{/* 좌측 패널 (템플릿, 컴포넌트) */}
|
||||
<ReportDesignerLeftPanel />
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<ReportDesignerCanvas />
|
||||
|
||||
{/* 우측 패널 (속성) */}
|
||||
<ReportDesignerRightPanel />
|
||||
</div>
|
||||
</div>
|
||||
<DesignerLayout />
|
||||
</ReportDesignerProvider>
|
||||
</DndProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ export default function ReportManagementPage() {
|
||||
const [tempStartDate, setTempStartDate] = useState<Date | undefined>(undefined);
|
||||
const [tempEndDate, setTempEndDate] = useState<Date | undefined>(undefined);
|
||||
const filterRef = useRef<HTMLDivElement>(null);
|
||||
const datePopoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
reports,
|
||||
@@ -71,10 +70,7 @@ export default function ReportManagementPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
const isInsideFilter = filterRef.current?.contains(target);
|
||||
const isInsideDatePopover = datePopoverRef.current?.contains(target);
|
||||
if (!isInsideFilter && !isInsideDatePopover) {
|
||||
if (filterRef.current && !filterRef.current.contains(e.target as Node)) {
|
||||
setFilterOpen(false);
|
||||
setDatePopoverOpen(false);
|
||||
}
|
||||
@@ -227,21 +223,8 @@ export default function ReportManagementPage() {
|
||||
|
||||
{filterOpen && datePopoverOpen && (
|
||||
<div
|
||||
ref={datePopoverRef}
|
||||
className="fixed z-50 rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||
style={(() => {
|
||||
const triggerRect = filterRef.current?.getBoundingClientRect();
|
||||
if (!triggerRect) return { top: 0, left: 0 };
|
||||
const popoverWidth = 620;
|
||||
const triggerCenter = triggerRect.left + triggerRect.width / 2;
|
||||
const top = triggerRect.bottom + 6;
|
||||
let left = triggerCenter - popoverWidth / 2;
|
||||
if (left < 8) left = 8;
|
||||
if (left + popoverWidth > window.innerWidth - 8) {
|
||||
left = window.innerWidth - popoverWidth - 8;
|
||||
}
|
||||
return { top, left };
|
||||
})()}
|
||||
className="absolute top-full right-0 z-50 mt-1.5 rounded-lg border border-gray-200 bg-white shadow-lg"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-4 py-3">
|
||||
<button
|
||||
@@ -411,7 +394,7 @@ export default function ReportManagementPage() {
|
||||
</Bar>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value}건`, "리포트"]}
|
||||
labelFormatter={(_label: string, payload: any[]) => payload?.[0]?.payload?.name || _label}
|
||||
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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -253,7 +253,7 @@ function DynamicAdminLoader({ url, params }: { url: string; params?: Record<stri
|
||||
|
||||
if (failed) return <AdminPageFallback url={url} />;
|
||||
if (!Component) return <LoadingFallback />;
|
||||
if (params) return <Component params={Promise.resolve(params)} />;
|
||||
if (params) return <Component params={Promise.resolve(params)} adminParams={params} />;
|
||||
return <Component />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { ReportMaster } from "@/types/report";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface ReportCopyModalProps {
|
||||
report: ReportMaster;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function ReportCopyModal({ report, onClose, onSuccess }: ReportCopyModalProps) {
|
||||
const [newName, setNewName] = useState(`${report.report_name_kor} (복사)`);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const initialNameRef = useRef(`${report.report_name_kor} (복사)`);
|
||||
const { toast } = useToast();
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
hasChanges: () => !isCopying && newName !== initialNameRef.current,
|
||||
onClose,
|
||||
title: "입력된 내용이 있습니다",
|
||||
description: "입력된 내용이 저장되지 않습니다. 정말 닫으시겠습니까?",
|
||||
});
|
||||
|
||||
const handleCopy = async () => {
|
||||
const trimmed = newName.trim();
|
||||
if (!trimmed) {
|
||||
toast({ title: "오류", description: "리포트 이름을 입력해주세요.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCopying(true);
|
||||
try {
|
||||
const response = await reportApi.copyReport(report.report_id, trimmed);
|
||||
if (response.success) {
|
||||
toast({ title: "성공", description: "리포트가 복사되었습니다." });
|
||||
onSuccess();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "리포트 복사에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open onOpenChange={guard.handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg">리포트 복사</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-5 py-3">
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="copy-name" className="text-base">
|
||||
새 리포트 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="copy-name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !isCopying && handleCopy()}
|
||||
placeholder="리포트 이름 입력"
|
||||
className="h-11 text-base"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={guard.tryClose} disabled={isCopying} className="text-base">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleCopy} disabled={isCopying} className="text-base">
|
||||
{isCopying ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
복사 중...
|
||||
</>
|
||||
) : (
|
||||
"복사"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<UnsavedChangesDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Loader2, LayoutTemplate, Check, ChevronsUpDown, Plus, Tag } from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { CreateReportRequest, ReportTemplate } from "@/types/report";
|
||||
import { REPORT_TYPE_OPTIONS, getTypeIcon, getTypeLabel } from "@/lib/reportTypeColors";
|
||||
import { ReportTemplate } from "@/types/report";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ReportCreateModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -24,59 +48,137 @@ interface ReportCreateModalProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const TEMPLATE_NONE = "__none__";
|
||||
|
||||
export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateModalProps) {
|
||||
const [formData, setFormData] = useState<CreateReportRequest>({
|
||||
reportNameKor: "",
|
||||
reportNameEng: "",
|
||||
templateId: undefined,
|
||||
reportType: "BASIC",
|
||||
description: "",
|
||||
});
|
||||
const [templates, setTemplates] = useState<ReportTemplate[]>([]);
|
||||
const router = useRouter();
|
||||
const [reportName, setReportName] = useState("");
|
||||
const [reportType, setReportType] = useState("");
|
||||
const [customCategory, setCustomCategory] = useState("");
|
||||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||||
const [description, setDescription] = useState("");
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState(TEMPLATE_NONE);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
|
||||
const [isLoadingCategories, setIsLoadingCategories] = useState(false);
|
||||
const [systemTemplates, setSystemTemplates] = useState<ReportTemplate[]>([]);
|
||||
const [customTemplates, setCustomTemplates] = useState<ReportTemplate[]>([]);
|
||||
const [existingCategories, setExistingCategories] = useState<string[]>([]);
|
||||
const { toast } = useToast();
|
||||
|
||||
// 템플릿 목록 불러오기
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchTemplates();
|
||||
}
|
||||
}, [isOpen]);
|
||||
if (!isOpen) return;
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
setIsLoadingTemplates(true);
|
||||
try {
|
||||
const response = await reportApi.getTemplates();
|
||||
if (response.success && response.data) {
|
||||
setTemplates([...response.data.system, ...response.data.custom]);
|
||||
setSystemTemplates(response.data.system || []);
|
||||
setCustomTemplates(response.data.custom || []);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "템플릿 목록을 불러오는데 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} catch {
|
||||
// 템플릿 로딩 실패 시 빈 목록으로 진행
|
||||
} finally {
|
||||
setIsLoadingTemplates(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCategories = async () => {
|
||||
setIsLoadingCategories(true);
|
||||
try {
|
||||
const response = await reportApi.getCategories();
|
||||
if (response.success && response.data) {
|
||||
setExistingCategories(response.data);
|
||||
}
|
||||
} catch {
|
||||
// 카테고리 로딩 실패 시 빈 목록으로 진행
|
||||
} finally {
|
||||
setIsLoadingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTemplates();
|
||||
fetchCategories();
|
||||
}, [isOpen]);
|
||||
|
||||
const hasTemplates = useMemo(
|
||||
() => systemTemplates.length > 0 || customTemplates.length > 0,
|
||||
[systemTemplates, customTemplates],
|
||||
);
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const defaultTypes = REPORT_TYPE_OPTIONS.map((opt) => opt.value);
|
||||
const merged = new Set([...defaultTypes, ...existingCategories]);
|
||||
return Array.from(merged).sort();
|
||||
}, [existingCategories]);
|
||||
|
||||
const effectiveCategory = useMemo(() => {
|
||||
return customCategory.trim() || reportType;
|
||||
}, [customCategory, reportType]);
|
||||
|
||||
const categoryDisplayLabel = useMemo(() => {
|
||||
if (customCategory.trim()) return customCategory.trim();
|
||||
if (reportType) return getTypeLabel(reportType);
|
||||
return "";
|
||||
}, [customCategory, reportType]);
|
||||
|
||||
const hasInputData = useCallback(() => {
|
||||
return reportName.trim() !== "" ||
|
||||
reportType !== "" ||
|
||||
customCategory.trim() !== "" ||
|
||||
description.trim() !== "" ||
|
||||
selectedTemplateId !== TEMPLATE_NONE;
|
||||
}, [reportName, reportType, customCategory, description, selectedTemplateId]);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setReportName("");
|
||||
setReportType("");
|
||||
setCustomCategory("");
|
||||
setDescription("");
|
||||
setSelectedTemplateId(TEMPLATE_NONE);
|
||||
}, []);
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
hasChanges: () => !isLoading && hasInputData(),
|
||||
onClose: () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
},
|
||||
title: "입력된 내용이 있습니다",
|
||||
description: "입력된 내용이 저장되지 않습니다. 정말 닫으시겠습니까?",
|
||||
});
|
||||
|
||||
const handleCategorySelect = (value: string) => {
|
||||
setReportType(value);
|
||||
setCustomCategory("");
|
||||
setCategoryOpen(false);
|
||||
};
|
||||
|
||||
const handleCustomCategoryAdd = () => {
|
||||
const trimmed = customCategory.trim();
|
||||
if (trimmed) {
|
||||
setReportType("");
|
||||
setCategoryOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 유효성 검증
|
||||
if (!formData.reportNameKor.trim()) {
|
||||
const trimmed = reportName.trim();
|
||||
if (!trimmed) {
|
||||
toast({
|
||||
title: "입력 오류",
|
||||
description: "리포트명(한글)을 입력해주세요.",
|
||||
description: "리포트명을 입력해주세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.reportType) {
|
||||
const finalCategory = effectiveCategory;
|
||||
if (!finalCategory) {
|
||||
toast({
|
||||
title: "입력 오류",
|
||||
description: "리포트 타입을 선택해주세요.",
|
||||
description: "카테고리를 선택하거나 입력해주세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
@@ -84,136 +186,212 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await reportApi.createReport(formData);
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "리포트가 생성되었습니다.",
|
||||
const response = await reportApi.createReport({
|
||||
reportNameKor: trimmed,
|
||||
reportType: finalCategory,
|
||||
description: description.trim() || undefined,
|
||||
templateId: selectedTemplateId !== TEMPLATE_NONE ? selectedTemplateId : undefined,
|
||||
});
|
||||
handleClose();
|
||||
|
||||
if (response.success && response.data) {
|
||||
toast({ title: "성공", description: "리포트가 생성되었습니다." });
|
||||
guard.doClose();
|
||||
onSuccess();
|
||||
router.push(`/admin/screenMng/reportList/designer/${response.data.reportId}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "리포트 생성에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : "리포트 생성에 실패했습니다.";
|
||||
toast({ title: "오류", description: msg, variant: "destructive" });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setFormData({
|
||||
reportNameKor: "",
|
||||
reportNameEng: "",
|
||||
templateId: undefined,
|
||||
reportType: "BASIC",
|
||||
description: "",
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={guard.handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 리포트 생성</DialogTitle>
|
||||
<DialogDescription>새로운 리포트를 생성합니다. 필수 항목을 입력해주세요.</DialogDescription>
|
||||
<DialogTitle className="text-lg">새 리포트 생성</DialogTitle>
|
||||
<DialogDescription className="text-base">
|
||||
리포트명과 카테고리를 입력한 후 디자이너에서 상세 설계를 진행합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 리포트명 (한글) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reportNameKor">
|
||||
리포트명 (한글) <span className="text-destructive">*</span>
|
||||
<div className="space-y-5 py-3">
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="create-report-name" className="text-base">
|
||||
리포트명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="reportNameKor"
|
||||
id="create-report-name"
|
||||
placeholder="예: 발주서"
|
||||
value={formData.reportNameKor}
|
||||
onChange={(e) => setFormData({ ...formData, reportNameKor: e.target.value })}
|
||||
value={reportName}
|
||||
onChange={(e) => setReportName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !isLoading && handleSubmit()}
|
||||
className="h-11 text-base"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 리포트명 (영문) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reportNameEng">리포트명 (영문)</Label>
|
||||
<Input
|
||||
id="reportNameEng"
|
||||
placeholder="예: Purchase Order"
|
||||
value={formData.reportNameEng}
|
||||
onChange={(e) => setFormData({ ...formData, reportNameEng: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="templateId">템플릿</Label>
|
||||
<Select
|
||||
value={formData.templateId || "none"}
|
||||
onValueChange={(value) => setFormData({ ...formData, templateId: value === "none" ? undefined : value })}
|
||||
disabled={isLoadingTemplates}
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="create-report-type" className="text-base">
|
||||
카테고리 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Popover open={categoryOpen} onOpenChange={setCategoryOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={categoryOpen}
|
||||
className="h-11 w-full justify-between text-base font-normal"
|
||||
disabled={isLoadingCategories}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="템플릿 선택 (선택사항)" />
|
||||
{isLoadingCategories ? (
|
||||
<span className="text-muted-foreground">카테고리 불러오는 중...</span>
|
||||
) : categoryDisplayLabel ? (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Tag className="h-4 w-4 shrink-0 text-gray-500" />
|
||||
<span>{categoryDisplayLabel}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">카테고리 선택 또는 입력</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="카테고리 검색 또는 새로 입력..."
|
||||
value={customCategory}
|
||||
onValueChange={setCustomCategory}
|
||||
className="text-base"
|
||||
/>
|
||||
<CommandList>
|
||||
{customCategory.trim() && !allCategories.includes(customCategory.trim()) && (
|
||||
<CommandGroup heading="새 카테고리 추가">
|
||||
<CommandItem
|
||||
value={`__new__${customCategory.trim()}`}
|
||||
onSelect={handleCustomCategoryAdd}
|
||||
className="text-base"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
"<span className="font-medium">{customCategory.trim()}</span>" 새로 추가
|
||||
</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandEmpty className="py-3 text-center text-sm text-muted-foreground">
|
||||
일치하는 카테고리가 없습니다.
|
||||
<br />
|
||||
위에 입력한 값으로 새 카테고리를 추가할 수 있습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup heading="기존 카테고리">
|
||||
{allCategories.map((cat) => {
|
||||
const Icon = getTypeIcon(cat);
|
||||
const label = getTypeLabel(cat);
|
||||
return (
|
||||
<CommandItem
|
||||
key={cat}
|
||||
value={cat}
|
||||
onSelect={() => handleCategorySelect(cat)}
|
||||
className="text-base"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
reportType === cat && !customCategory.trim() ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Icon className="mr-2 h-4 w-4 shrink-0 text-gray-500" />
|
||||
<span>{label}</span>
|
||||
{cat !== label && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">({cat})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
기존 카테고리를 선택하거나 새로운 카테고리를 직접 입력할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="create-report-template" className="text-base">
|
||||
템플릿
|
||||
</Label>
|
||||
<Select value={selectedTemplateId} onValueChange={setSelectedTemplateId} disabled={isLoadingTemplates}>
|
||||
<SelectTrigger className="h-11 text-base">
|
||||
<SelectValue placeholder={isLoadingTemplates ? "템플릿 불러오는 중..." : "템플릿 선택 (선택사항)"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">템플릿 없음</SelectItem>
|
||||
{templates.map((template) => (
|
||||
<SelectItem key={template.template_id} value={template.template_id}>
|
||||
{template.template_name_kor}
|
||||
{template.is_system === "Y" && " (시스템)"}
|
||||
<SelectItem value={TEMPLATE_NONE} className="text-base">
|
||||
<span className="text-gray-500">템플릿 없이 시작</span>
|
||||
</SelectItem>
|
||||
{systemTemplates.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-400">시스템 템플릿</div>
|
||||
{systemTemplates.map((t) => (
|
||||
<SelectItem key={t.template_id} value={t.template_id} className="text-base">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LayoutTemplate className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<span>{t.template_name_kor}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{customTemplates.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-400">사용자 템플릿</div>
|
||||
{customTemplates.map((t) => (
|
||||
<SelectItem key={t.template_id} value={t.template_id} className="text-base">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<LayoutTemplate className="h-4 w-4 shrink-0 text-green-500" />
|
||||
<span>{t.template_name_kor}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{!isLoadingTemplates && !hasTemplates && (
|
||||
<div className="px-2 py-2 text-sm text-gray-400">등록된 템플릿이 없습니다</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">템플릿을 선택하면 레이아웃이 자동으로 적용됩니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 리포트 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reportType">
|
||||
리포트 타입 <span className="text-destructive">*</span>
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="create-report-desc" className="text-base">
|
||||
설명
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.reportType}
|
||||
onValueChange={(value) => setFormData({ ...formData, reportType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ORDER">발주서</SelectItem>
|
||||
<SelectItem value="INVOICE">청구서</SelectItem>
|
||||
<SelectItem value="STATEMENT">거래명세서</SelectItem>
|
||||
<SelectItem value="RECEIPT">영수증</SelectItem>
|
||||
<SelectItem value="BASIC">기본</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="리포트에 대한 설명을 입력하세요"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
id="create-report-desc"
|
||||
placeholder="리포트에 대한 간단한 설명을 입력하세요"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="min-h-[80px] resize-none text-base"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
|
||||
<Button variant="outline" onClick={guard.tryClose} disabled={isLoading} className="text-base">
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isLoading}>
|
||||
<Button onClick={handleSubmit} disabled={isLoading} className="text-base">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
생성 중...
|
||||
</>
|
||||
) : (
|
||||
@@ -223,5 +401,8 @@ export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateMo
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<UnsavedChangesDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
"use client";
|
||||
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileDown, FileText, Loader2 } from "lucide-react";
|
||||
import { ComponentConfig, ReportDetail, ReportMaster, ReportPage, ReportQuery, WatermarkConfig } from "@/types/report";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import {
|
||||
TextRenderer,
|
||||
TableRenderer,
|
||||
ImageRenderer,
|
||||
DividerRenderer,
|
||||
SignatureRenderer,
|
||||
StampRenderer,
|
||||
PageNumberRenderer,
|
||||
CardRenderer,
|
||||
CalculationRenderer,
|
||||
BarcodeCanvasRenderer,
|
||||
CheckboxRenderer,
|
||||
} from "./designer/renderers";
|
||||
import { MM_TO_PX } from "@/lib/report/constants";
|
||||
|
||||
interface QueryResult {
|
||||
queryId: string;
|
||||
fields: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
interface ReportListPreviewModalProps {
|
||||
report: ReportMaster | null;
|
||||
onClose: () => void;
|
||||
/** 컨텍스트에서 자동 주입할 쿼리 파라미터 (formData 기반) */
|
||||
contextParams?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function ReportListPreviewModal({ report, onClose, contextParams }: ReportListPreviewModalProps) {
|
||||
const [detail, setDetail] = useState<ReportDetail | null>(null);
|
||||
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const getQueryResult = useCallback(
|
||||
(queryId: string): QueryResult | null => {
|
||||
return queryResults.find((r) => r.queryId === queryId) || null;
|
||||
},
|
||||
[queryResults],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!report) {
|
||||
setDetail(null);
|
||||
setQueryResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await reportApi.getReportById(report.report_id);
|
||||
if (cancelled || !res.success) return;
|
||||
|
||||
setDetail(res.data);
|
||||
|
||||
// 쿼리 자동 실행
|
||||
const queries: ReportQuery[] = res.data.queries ?? [];
|
||||
if (queries.length === 0) return;
|
||||
|
||||
// contextParams를 $1, $2 ... 형식으로 매핑 (휴리스틱)
|
||||
const contextEntries = Object.values(contextParams ?? {});
|
||||
const buildParams = (parameters: string[]): Record<string, unknown> => {
|
||||
const result: Record<string, unknown> = {};
|
||||
parameters.forEach((param, idx) => {
|
||||
result[param] = contextEntries[idx] ?? contextParams?.[param] ?? null;
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const results: QueryResult[] = [];
|
||||
for (const q of queries) {
|
||||
try {
|
||||
const params = buildParams(q.parameters ?? []);
|
||||
const execRes = await reportApi.executeQuery(
|
||||
report.report_id,
|
||||
q.query_id,
|
||||
params,
|
||||
q.sql_query,
|
||||
q.external_connection_id,
|
||||
);
|
||||
if (execRes.success && execRes.data) {
|
||||
results.push({
|
||||
queryId: q.query_id,
|
||||
fields: execRes.data.fields,
|
||||
rows: execRes.data.rows,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 개별 쿼리 실패는 무시
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) setQueryResults(results);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
toast({ title: "오류", description: "리포트를 불러올 수 없습니다.", variant: "destructive" });
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [report?.report_id, contextParams]);
|
||||
|
||||
const { pages, watermark } = useMemo(() => {
|
||||
const empty = { pages: [] as ReportPage[], watermark: undefined as WatermarkConfig | undefined };
|
||||
if (!detail?.layout) return empty;
|
||||
|
||||
const layout = detail.layout as unknown as Record<string, unknown>;
|
||||
|
||||
let config: Record<string, unknown> | null = null;
|
||||
|
||||
let raw: unknown = layout.components;
|
||||
|
||||
while (typeof raw === "string") {
|
||||
try {
|
||||
raw = JSON.parse(raw);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
||||
config = raw as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (!config && Array.isArray(layout.pages)) {
|
||||
config = layout;
|
||||
}
|
||||
|
||||
if (!config) return empty;
|
||||
|
||||
const foundPages = Array.isArray(config.pages) ? (config.pages as ReportPage[]) : [];
|
||||
const foundWatermark = config.watermark as WatermarkConfig | undefined;
|
||||
|
||||
return { pages: foundPages, watermark: foundWatermark };
|
||||
}, [detail?.layout]);
|
||||
|
||||
const handleDownloadPDF = async () => {
|
||||
if (!previewRef.current || pages.length === 0) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const [{ jsPDF }, html2canvas] = await Promise.all([
|
||||
import("jspdf"),
|
||||
import("html2canvas").then((m) => m.default),
|
||||
]);
|
||||
|
||||
const pageEls = previewRef.current.querySelectorAll<HTMLElement>("[data-list-preview-page]");
|
||||
if (pageEls.length === 0) return;
|
||||
|
||||
const firstPage = pages[0];
|
||||
const doc = new jsPDF({
|
||||
orientation: firstPage.orientation === "landscape" ? "l" : "p",
|
||||
unit: "mm",
|
||||
format: [firstPage.width, firstPage.height],
|
||||
});
|
||||
|
||||
for (let i = 0; i < pageEls.length; i++) {
|
||||
const canvas = await html2canvas(pageEls[i], {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: "#ffffff",
|
||||
});
|
||||
|
||||
if (i > 0) {
|
||||
const p = pages[i] ?? firstPage;
|
||||
doc.addPage([p.width, p.height], p.orientation === "landscape" ? "l" : "p");
|
||||
}
|
||||
|
||||
const p = pages[i] ?? firstPage;
|
||||
doc.addImage(canvas.toDataURL("image/jpeg", 0.92), "JPEG", 0, 0, p.width, p.height);
|
||||
}
|
||||
|
||||
doc.save(`${report?.report_name_kor ?? "report"}.pdf`);
|
||||
} catch {
|
||||
toast({ title: "오류", description: "PDF 다운로드에 실패했습니다.", variant: "destructive" });
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || pages.length === 0) return;
|
||||
|
||||
const calculateScale = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const firstPage = pages[0];
|
||||
const pageWidthPx = firstPage.width * MM_TO_PX;
|
||||
const pageHeightPx = firstPage.height * MM_TO_PX;
|
||||
|
||||
const availableWidth = container.clientWidth - 48;
|
||||
const availableHeight = container.clientHeight - 48;
|
||||
|
||||
const scaleX = availableWidth / pageWidthPx;
|
||||
const scaleY = availableHeight / pageHeightPx;
|
||||
setScale(Math.min(scaleX, scaleY, 1));
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(calculateScale);
|
||||
observer.observe(containerRef.current);
|
||||
calculateScale();
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [pages]);
|
||||
|
||||
return (
|
||||
<Dialog open={!!report} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
className="flex flex-col gap-0 p-0"
|
||||
style={{ height: "85vh", width: "calc(85vh / 1.414)", maxWidth: "95vw" }}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-7 py-5">
|
||||
<DialogTitle className="flex items-center gap-2.5 text-lg">
|
||||
<FileText className="h-6 w-6 text-blue-600" />
|
||||
{report?.report_name_kor ?? "리포트 미리보기"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div ref={containerRef} className="min-h-0 flex-1 overflow-auto bg-gray-100">
|
||||
{isLoading ? (
|
||||
<div className="flex h-72 items-center justify-center">
|
||||
<Loader2 className="h-10 w-10 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : pages.length === 0 ? (
|
||||
<div className="flex h-72 flex-col items-center justify-center gap-3 text-gray-400">
|
||||
<FileText className="h-14 w-14 opacity-30" />
|
||||
<p className="text-base">{detail ? "저장된 레이아웃이 없습니다." : "데이터를 불러오는 중..."}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={previewRef} className="flex flex-col items-center p-6" style={{ gap: `${24 * scale}px` }}>
|
||||
{[...pages]
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.map((page, pageIndex) => (
|
||||
<div
|
||||
key={page.page_id}
|
||||
style={{
|
||||
width: `${Math.ceil(page.width * MM_TO_PX * scale) + 1}px`,
|
||||
height: `${Math.ceil(page.height * MM_TO_PX * scale) + 1}px`,
|
||||
flexShrink: 0,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
width: `${page.width * MM_TO_PX}px`,
|
||||
height: `${page.height * MM_TO_PX}px`,
|
||||
}}
|
||||
>
|
||||
<PagePreview
|
||||
page={page}
|
||||
pageIndex={pageIndex}
|
||||
totalPages={pages.length}
|
||||
pages={pages}
|
||||
watermark={watermark}
|
||||
getQueryResult={getQueryResult}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t bg-white px-7 py-5">
|
||||
<Button variant="outline" onClick={onClose} className="text-base">
|
||||
닫기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownloadPDF}
|
||||
disabled={isExporting || isLoading || pages.length === 0}
|
||||
className="gap-2 bg-blue-600 text-base text-white hover:bg-blue-700"
|
||||
>
|
||||
{isExporting ? <Loader2 className="h-5 w-5 animate-spin" /> : <FileDown className="h-5 w-5" />}
|
||||
PDF 다운로드
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function WatermarkLayer({
|
||||
watermark,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
}: {
|
||||
watermark: WatermarkConfig;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
}) {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
zIndex: 0,
|
||||
};
|
||||
|
||||
const rotation = watermark.rotation ?? -45;
|
||||
|
||||
const textOrImage =
|
||||
watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 48}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : watermark.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={watermark.imageUrl.startsWith("data:") ? watermark.imageUrl : getFullImageUrl(watermark.imageUrl)}
|
||||
alt=""
|
||||
style={{ maxWidth: "50%", maxHeight: "50%", objectFit: "contain" }}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (watermark.style === "diagonal") {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{textOrImage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (watermark.style === "center") {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
opacity: watermark.opacity,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{textOrImage}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (watermark.style === "tile") {
|
||||
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
||||
const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2;
|
||||
const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2;
|
||||
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-50%",
|
||||
left: "-50%",
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignContent: "flex-start",
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: rows * cols }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: `${tileSize}px`,
|
||||
height: `${tileSize}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{textOrImage}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function PagePreview({
|
||||
page,
|
||||
pageIndex,
|
||||
totalPages,
|
||||
pages,
|
||||
watermark,
|
||||
getQueryResult,
|
||||
}: {
|
||||
page: ReportPage;
|
||||
pageIndex: number;
|
||||
totalPages: number;
|
||||
pages: ReportPage[];
|
||||
watermark?: WatermarkConfig;
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-list-preview-page={page.page_id}
|
||||
className="relative shadow-md"
|
||||
style={{
|
||||
width: `${page.width * MM_TO_PX}px`,
|
||||
height: `${page.height * MM_TO_PX}px`,
|
||||
backgroundColor: page.background_color || "#ffffff",
|
||||
flexShrink: 0,
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
{watermark?.enabled && <WatermarkLayer watermark={watermark} pageWidth={page.width} pageHeight={page.height} />}
|
||||
|
||||
{(page.components ?? [])
|
||||
.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0))
|
||||
.map((comp) => (
|
||||
<ComponentRenderer
|
||||
key={comp.id}
|
||||
comp={comp}
|
||||
pageIndex={pageIndex}
|
||||
totalPages={totalPages}
|
||||
pages={pages}
|
||||
getQueryResult={getQueryResult}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BarcodePreviewRenderer({
|
||||
component,
|
||||
getQueryResult,
|
||||
}: {
|
||||
component: ComponentConfig;
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
}) {
|
||||
return <BarcodeCanvasRenderer component={component} getQueryResult={getQueryResult} />;
|
||||
}
|
||||
|
||||
function ComponentRenderer({
|
||||
comp,
|
||||
pageIndex,
|
||||
totalPages,
|
||||
pages,
|
||||
getQueryResult,
|
||||
}: {
|
||||
comp: ComponentConfig;
|
||||
pageIndex: number;
|
||||
totalPages: number;
|
||||
pages: ReportPage[];
|
||||
getQueryResult: (queryId: string) => QueryResult | null;
|
||||
}) {
|
||||
const isDivider = comp.type === "divider";
|
||||
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: `${comp.x}px`,
|
||||
top: `${comp.y}px`,
|
||||
width: `${comp.width}px`,
|
||||
height: `${comp.height}px`,
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
zIndex: comp.zIndex ?? 0,
|
||||
backgroundColor: comp.backgroundColor || "transparent",
|
||||
...(comp.borderWidth
|
||||
? { borderWidth: `${comp.borderWidth}px`, borderColor: comp.borderColor || "#000", borderStyle: "solid" }
|
||||
: {}),
|
||||
...(comp.borderRadius ? { borderRadius: `${comp.borderRadius}px` } : {}),
|
||||
padding: isDivider
|
||||
? 0
|
||||
: comp.padding != null
|
||||
? typeof comp.padding === "number"
|
||||
? `${comp.padding}px`
|
||||
: comp.padding
|
||||
: "8px",
|
||||
};
|
||||
|
||||
const getComponentValue = (c: ComponentConfig): string => {
|
||||
if (c.queryId && c.fieldName) {
|
||||
const qr = getQueryResult(c.queryId);
|
||||
if (qr?.rows?.length) {
|
||||
const val = qr.rows[0][c.fieldName];
|
||||
if (val != null) return String(val);
|
||||
}
|
||||
return `{${c.fieldName}}`;
|
||||
}
|
||||
return c.defaultValue || "";
|
||||
};
|
||||
|
||||
const displayValue = getComponentValue(comp);
|
||||
|
||||
const sortedPages = [...pages].sort((a, b) => a.page_order - b.page_order);
|
||||
const currentPageId = sortedPages[pageIndex]?.page_id ?? null;
|
||||
const layoutConfig = { pages: sortedPages.map((p) => ({ page_id: p.page_id, page_order: p.page_order })) };
|
||||
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
{(comp.type === "text" || comp.type === "label") && (
|
||||
<TextRenderer component={comp} displayValue={displayValue} getQueryResult={getQueryResult} />
|
||||
)}
|
||||
|
||||
{comp.type === "table" && (
|
||||
<TableRenderer component={comp} getQueryResult={getQueryResult} />
|
||||
)}
|
||||
|
||||
{comp.type === "image" && <ImageRenderer component={comp} />}
|
||||
|
||||
{comp.type === "divider" && <DividerRenderer component={comp} />}
|
||||
|
||||
{comp.type === "signature" && <SignatureRenderer component={comp} />}
|
||||
|
||||
{comp.type === "stamp" && <StampRenderer component={comp} />}
|
||||
|
||||
{comp.type === "pageNumber" && (
|
||||
<PageNumberRenderer component={comp} currentPageId={currentPageId} layoutConfig={layoutConfig} />
|
||||
)}
|
||||
|
||||
{comp.type === "card" && (
|
||||
<CardRenderer component={comp} getQueryResult={getQueryResult} />
|
||||
)}
|
||||
|
||||
{comp.type === "calculation" && (
|
||||
<CalculationRenderer component={comp} getQueryResult={getQueryResult} />
|
||||
)}
|
||||
|
||||
{comp.type === "barcode" && (
|
||||
<BarcodeCanvasRenderer component={comp} getQueryResult={getQueryResult} />
|
||||
)}
|
||||
|
||||
{comp.type === "checkbox" && (
|
||||
<CheckboxRenderer component={comp} getQueryResult={getQueryResult} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { ReportMaster } from "@/types/report";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -14,11 +12,15 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Copy, Trash2, Loader2 } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Copy, Trash2, Edit, Eye, FileText, Calendar, User, Loader2, Pencil, AlignLeft } from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { format } from "date-fns";
|
||||
import { getTypeBgClass, getTypeLabel, getTypeIcon } from "@/lib/reportTypeColors";
|
||||
|
||||
interface ReportListTableProps {
|
||||
reports: ReportMaster[];
|
||||
@@ -26,8 +28,11 @@ interface ReportListTableProps {
|
||||
page: number;
|
||||
limit: number;
|
||||
isLoading: boolean;
|
||||
viewMode: "grid" | "list";
|
||||
onPageChange: (page: number) => void;
|
||||
onRefresh: () => void;
|
||||
onViewClick: (report: ReportMaster) => void;
|
||||
onCopyClick: (report: ReportMaster) => void;
|
||||
}
|
||||
|
||||
export function ReportListTable({
|
||||
@@ -36,62 +41,34 @@ export function ReportListTable({
|
||||
page,
|
||||
limit,
|
||||
isLoading,
|
||||
viewMode,
|
||||
onPageChange,
|
||||
onRefresh,
|
||||
onViewClick,
|
||||
onCopyClick,
|
||||
}: ReportListTableProps) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const openTab = useTabStore((s) => s.openTab);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
// 수정
|
||||
const handleEdit = (reportId: string) => {
|
||||
router.push(`/admin/screenMng/reportList/designer/${reportId}`);
|
||||
};
|
||||
|
||||
// 복사
|
||||
const handleCopy = async (reportId: string) => {
|
||||
setIsCopying(true);
|
||||
try {
|
||||
const response = await reportApi.copyReport(reportId);
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "리포트가 복사되었습니다.",
|
||||
openTab({
|
||||
type: "admin",
|
||||
title: "리포트 디자이너",
|
||||
adminUrl: `/admin/screenMng/reportList/designer/${reportId}`,
|
||||
});
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "리포트 복사에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteClick = (reportId: string) => {
|
||||
setDeleteTarget(reportId);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await reportApi.deleteReport(deleteTarget);
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "리포트가 삭제되었습니다.",
|
||||
});
|
||||
toast({ title: "성공", description: "리포트가 삭제되었습니다." });
|
||||
setDeleteTarget(null);
|
||||
onRefresh();
|
||||
}
|
||||
@@ -106,7 +83,6 @@ export function ReportListTable({
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return "-";
|
||||
try {
|
||||
@@ -116,121 +92,144 @@ export function ReportListTable({
|
||||
}
|
||||
};
|
||||
|
||||
const formatUpdatedDate = (updatedAt: string | null, createdAt: string | null) => {
|
||||
if (!updatedAt) return "-";
|
||||
try {
|
||||
const updatedStr = format(new Date(updatedAt), "yyyy-MM-dd HH:mm:ss");
|
||||
const createdStr = createdAt ? format(new Date(createdAt), "yyyy-MM-dd HH:mm:ss") : null;
|
||||
if (createdStr && updatedStr === createdStr) return "-";
|
||||
return format(new Date(updatedAt), "yyyy-MM-dd");
|
||||
} catch {
|
||||
return updatedAt || "-";
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
<div className="flex h-72 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-10 w-10 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reports.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 flex-col items-center justify-center">
|
||||
<p>등록된 리포트가 없습니다.</p>
|
||||
<div className="text-muted-foreground flex h-72 flex-col items-center justify-center gap-3">
|
||||
<FileText className="h-12 w-12 opacity-30" />
|
||||
<p className="text-base">등록된 리포트가 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRename = async (reportId: string, newName: string) => {
|
||||
try {
|
||||
const response = await reportApi.updateReport(reportId, { reportNameKor: newName });
|
||||
if (response.success) {
|
||||
toast({ title: "성공", description: "리포트명이 변경되었습니다." });
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "리포트명 변경에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDescriptionChange = async (reportId: string, newDesc: string) => {
|
||||
try {
|
||||
const response = await reportApi.updateReport(reportId, { description: newDesc });
|
||||
if (response.success) {
|
||||
toast({ title: "성공", description: "설명이 변경되었습니다." });
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "설명 변경에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[80px]">No</TableHead>
|
||||
<TableHead>리포트명</TableHead>
|
||||
<TableHead className="w-[120px]">작성자</TableHead>
|
||||
<TableHead className="w-[120px]">수정일</TableHead>
|
||||
<TableHead className="w-[200px]">액션</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reports.map((report, index) => {
|
||||
const rowNumber = (page - 1) * limit + index + 1;
|
||||
return (
|
||||
<TableRow
|
||||
key={report.report_id}
|
||||
onClick={() => handleEdit(report.report_id)}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<TableCell className="font-medium">{rowNumber}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{report.report_name_kor}</div>
|
||||
{report.report_name_eng && (
|
||||
<div className="text-muted-foreground text-sm">{report.report_name_eng}</div>
|
||||
{viewMode === "grid" ? (
|
||||
<GridView
|
||||
reports={reports}
|
||||
page={page}
|
||||
limit={limit}
|
||||
onEdit={handleEdit}
|
||||
onView={onViewClick}
|
||||
onCopyClick={onCopyClick}
|
||||
onDeleteClick={setDeleteTarget}
|
||||
onRename={handleRename}
|
||||
onDescriptionChange={handleDescriptionChange}
|
||||
formatDate={formatDate}
|
||||
formatUpdatedDate={formatUpdatedDate}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
reports={reports}
|
||||
page={page}
|
||||
limit={limit}
|
||||
onEdit={handleEdit}
|
||||
onView={onViewClick}
|
||||
onCopyClick={onCopyClick}
|
||||
onDeleteClick={setDeleteTarget}
|
||||
onRename={handleRename}
|
||||
onDescriptionChange={handleDescriptionChange}
|
||||
formatDate={formatDate}
|
||||
formatUpdatedDate={formatUpdatedDate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{report.created_by || "-"}</TableCell>
|
||||
<TableCell>{formatDate(report.updated_at || report.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
onClick={() => handleCopy(report.report_id)}
|
||||
disabled={isCopying}
|
||||
className="h-8 w-8"
|
||||
title="복사"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteClick(report.report_id)}
|
||||
className="h-8 w-8"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 p-4">
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(page - 1)} disabled={page === 1}>
|
||||
이전
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{page} / {totalPages}
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-gray-50 px-6 py-3">
|
||||
<span className="text-sm text-gray-500">
|
||||
총 <span className="font-semibold text-gray-900">{total}건</span>의 리포트
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(page + 1)} disabled={page === totalPages}>
|
||||
다음
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||
<Button
|
||||
key={p}
|
||||
variant={p === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(p)}
|
||||
className={`h-8 w-8 p-0 text-sm ${
|
||||
p === page ? "bg-blue-600 text-white hover:bg-blue-700" : ""
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>리포트 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<AlertDialogTitle className="text-lg">리포트 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-base">
|
||||
이 리포트를 삭제하시겠습니까?
|
||||
<br />
|
||||
삭제된 리포트는 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isDeleting} className="text-base">취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90 text-base"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
@@ -243,3 +242,394 @@ export function ReportListTable({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ViewProps {
|
||||
reports: ReportMaster[];
|
||||
page: number;
|
||||
limit: number;
|
||||
onEdit: (id: string) => void;
|
||||
onView: (report: ReportMaster) => void;
|
||||
onCopyClick: (report: ReportMaster) => void;
|
||||
onDeleteClick: (id: string) => void;
|
||||
onRename: (reportId: string, newName: string) => Promise<void>;
|
||||
onDescriptionChange: (reportId: string, newDesc: string) => Promise<void>;
|
||||
formatDate: (d: string | null) => string;
|
||||
formatUpdatedDate: (updatedAt: string | null, createdAt: string | null) => string;
|
||||
}
|
||||
|
||||
function InlineReportName({
|
||||
reportId,
|
||||
name,
|
||||
onNavigate,
|
||||
onRename,
|
||||
}: {
|
||||
reportId: string;
|
||||
name: string;
|
||||
onNavigate: () => void;
|
||||
onRename: (reportId: string, newName: string) => Promise<void>;
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(name);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmed = editValue.trim();
|
||||
if (!trimmed || trimmed === name) {
|
||||
setIsEditing(false);
|
||||
setEditValue(name);
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onRename(reportId, trimmed);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [editValue, name, reportId, onRename]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setEditValue(name);
|
||||
}, [name]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
},
|
||||
[handleSave, handleCancel],
|
||||
);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
disabled={isSaving}
|
||||
className="h-7 text-sm font-medium"
|
||||
/>
|
||||
{isSaving && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-gray-400" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/name flex min-w-0 items-center gap-1.5">
|
||||
<button
|
||||
onClick={onNavigate}
|
||||
className="cursor-pointer truncate text-left text-sm font-semibold text-gray-900 hover:text-blue-600 hover:underline"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-gray-100 group-hover/name:opacity-100"
|
||||
title="리포트명 수정"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineDescription({
|
||||
reportId,
|
||||
description,
|
||||
onSave,
|
||||
}: {
|
||||
reportId: string;
|
||||
description: string | null;
|
||||
onSave: (reportId: string, newDesc: string) => Promise<void>;
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(description || "");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed === (description || "")) {
|
||||
setIsEditing(false);
|
||||
setEditValue(description || "");
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(reportId, trimmed);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [editValue, description, reportId, onSave]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setIsEditing(false);
|
||||
setEditValue(description || "");
|
||||
}, [description]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
},
|
||||
[handleSave, handleCancel],
|
||||
);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleSave}
|
||||
disabled={isSaving}
|
||||
placeholder="설명 입력"
|
||||
className="h-6 text-xs"
|
||||
/>
|
||||
{isSaving && <Loader2 className="h-4 w-4 shrink-0 animate-spin text-gray-400" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/desc flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className="cursor-pointer truncate text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setIsEditing(true)}
|
||||
title={description || "클릭하여 설명 입력"}
|
||||
>
|
||||
{description || <span className="italic text-gray-300">Inline Description</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-gray-100 group-hover/desc:opacity-100"
|
||||
title="설명 수정"
|
||||
>
|
||||
<Pencil className="h-3 w-3 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListView({ reports, page, limit, onEdit, onView, onCopyClick, onDeleteClick, onRename, onDescriptionChange, formatDate, formatUpdatedDate }: ViewProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<colgroup>
|
||||
<col style={{ width: 50 }} />
|
||||
<col style={{ width: "20%" }} />
|
||||
<col style={{ width: "15%" }} />
|
||||
<col style={{ width: 100 }} />
|
||||
<col style={{ width: 70 }} />
|
||||
<col style={{ width: 110 }} />
|
||||
<col style={{ width: 110 }} />
|
||||
<col style={{ width: 150 }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50">
|
||||
<th className="px-3 py-3 text-center text-sm font-semibold text-gray-500">NO</th>
|
||||
<th className="px-3 py-3 text-left text-sm font-semibold text-gray-500">리포트명</th>
|
||||
<th className="px-3 py-3 text-left text-sm font-semibold text-gray-500">설명</th>
|
||||
<th className="px-3 py-3 text-center text-sm font-semibold text-gray-500">카테고리</th>
|
||||
<th className="px-3 py-3 text-center text-sm font-semibold text-gray-500">작성자</th>
|
||||
<th className="px-3 py-3 text-center text-sm font-semibold text-gray-500">생성일</th>
|
||||
<th className="px-3 py-3 text-center text-sm font-semibold text-gray-500">수정일</th>
|
||||
<th className="px-3 py-3 text-center text-sm font-semibold text-gray-500">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{reports.map((report, index) => {
|
||||
const rowNumber = (page - 1) * limit + index + 1;
|
||||
return (
|
||||
<tr key={report.report_id} className="transition-colors hover:bg-blue-50/70">
|
||||
<td className="px-3 py-3.5 text-center text-sm font-medium text-gray-400">
|
||||
{rowNumber}
|
||||
</td>
|
||||
<td className="px-3 py-3.5">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-gradient-to-br from-blue-500 to-blue-600">
|
||||
<FileText className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<InlineReportName
|
||||
reportId={report.report_id}
|
||||
name={report.report_name_kor}
|
||||
onNavigate={() => onEdit(report.report_id)}
|
||||
onRename={onRename}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="min-w-0 overflow-hidden px-3 py-3.5">
|
||||
<InlineDescription
|
||||
reportId={report.report_id}
|
||||
description={report.description}
|
||||
onSave={onDescriptionChange}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3.5 text-center">
|
||||
{report.report_type && (() => {
|
||||
const TypeIcon = getTypeIcon(report.report_type);
|
||||
return (
|
||||
<Badge className={`gap-1.5 whitespace-nowrap text-sm leading-tight hover:bg-transparent ${getTypeBgClass(report.report_type)}`}>
|
||||
<TypeIcon className="h-3.5 w-3.5" strokeWidth={2.2} />
|
||||
{getTypeLabel(report.report_type)}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="px-3 py-3.5 text-center text-sm text-gray-600">
|
||||
{report.created_by || "-"}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3.5 text-center text-sm text-gray-500">
|
||||
{formatDate(report.created_at)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-3 py-3.5 text-center text-sm text-gray-500">
|
||||
{formatUpdatedDate(report.updated_at, report.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-3.5">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => onView(report)} className="h-8 w-8" title="미리보기">
|
||||
<Eye className="h-4 w-4 text-gray-500" strokeWidth={2} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => onEdit(report.report_id)} className="h-8 w-8" title="수정">
|
||||
<Edit className="h-4 w-4 text-gray-500" strokeWidth={2} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => onCopyClick(report)} className="h-8 w-8" title="복사">
|
||||
<Copy className="h-4 w-4 text-gray-500" strokeWidth={2} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => onDeleteClick(report.report_id)} className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600" title="삭제">
|
||||
<Trash2 className="h-4 w-4" strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GridView({ reports, page, limit, onEdit, onView, onCopyClick, onDeleteClick, onRename, onDescriptionChange, formatDate, formatUpdatedDate }: ViewProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 px-4 py-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{reports.map((report, index) => {
|
||||
const rowNumber = (page - 1) * limit + index + 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={report.report_id}
|
||||
className="group rounded-lg border border-gray-200 bg-white px-4 py-3 transition-all hover:border-blue-300 hover:bg-blue-50/50 hover:shadow-md"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="ml-1 flex min-w-0 items-center gap-2">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-gradient-to-br from-blue-500 to-blue-600">
|
||||
<FileText className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<InlineReportName
|
||||
reportId={report.report_id}
|
||||
name={report.report_name_kor}
|
||||
onNavigate={() => onEdit(report.report_id)}
|
||||
onRename={onRename}
|
||||
/>
|
||||
{report.report_type && (() => {
|
||||
const TypeIcon = getTypeIcon(report.report_type);
|
||||
return (
|
||||
<Badge className={`-ml-1.5 shrink-0 gap-1 text-[11px] leading-tight hover:bg-transparent ${getTypeBgClass(report.report_type)}`}>
|
||||
<TypeIcon className="h-2.5 w-2.5" strokeWidth={2.5} />
|
||||
{getTypeLabel(report.report_type)}
|
||||
</Badge>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] font-medium text-gray-400">#{rowNumber}</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-3 space-y-1 text-xs">
|
||||
<div className="flex items-center text-gray-600">
|
||||
<User className="mr-1.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span className="w-[38px] shrink-0 font-medium text-gray-700">작성자</span>
|
||||
<span>{report.created_by || "-"}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Calendar className="mr-1.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span className="w-[38px] shrink-0 font-medium text-gray-700">생성일</span>
|
||||
<span>{formatDate(report.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<Calendar className="mr-1.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span className="w-[38px] shrink-0 font-medium text-gray-700">수정일</span>
|
||||
<span>{formatUpdatedDate(report.updated_at, report.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<AlignLeft className="mr-1.5 h-3.5 w-3.5 shrink-0" />
|
||||
<span className="w-[38px] shrink-0 font-medium text-gray-700">설명</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<InlineDescription
|
||||
reportId={report.report_id}
|
||||
description={report.description}
|
||||
onSave={onDescriptionChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex gap-1.5 border-t border-gray-100 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onView(report)} className="h-7 flex-1 gap-1 px-0 text-[11px]">
|
||||
<Eye className="h-3 w-3" />
|
||||
미리보기
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onEdit(report.report_id)} className="h-7 flex-1 gap-1 px-0 text-[11px]">
|
||||
<Edit className="h-3 w-3" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onCopyClick(report)} className="h-7 flex-1 gap-1 px-0 text-[11px]">
|
||||
<Copy className="h-3 w-3" />
|
||||
복사
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => onDeleteClick(report.report_id)} className="h-7 flex-1 gap-1 px-0 text-[11px] text-red-600 hover:border-red-200 hover:bg-red-50 hover:text-red-700">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useDrag } from "react-dnd";
|
||||
import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard, Calculator, Barcode, CheckSquare } from "lucide-react";
|
||||
import {
|
||||
Type,
|
||||
Table,
|
||||
Image,
|
||||
Minus,
|
||||
PenLine,
|
||||
Stamp as StampIcon,
|
||||
Hash,
|
||||
CreditCard,
|
||||
Calculator,
|
||||
Barcode,
|
||||
CheckSquare,
|
||||
ChevronRight,
|
||||
LayoutGrid,
|
||||
FileText,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
|
||||
interface ComponentItem {
|
||||
type: string;
|
||||
@@ -9,18 +26,45 @@ interface ComponentItem {
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const COMPONENTS: ComponentItem[] = [
|
||||
{ type: "text", label: "텍스트", icon: <Type className="h-4 w-4" /> },
|
||||
{ type: "table", label: "테이블", icon: <Table className="h-4 w-4" /> },
|
||||
{ type: "image", label: "이미지", icon: <Image className="h-4 w-4" /> },
|
||||
{ type: "divider", label: "구분선", icon: <Minus className="h-4 w-4" /> },
|
||||
{ type: "signature", label: "서명란", icon: <PenLine className="h-4 w-4" /> },
|
||||
{ type: "stamp", label: "도장란", icon: <StampIcon className="h-4 w-4" /> },
|
||||
{ type: "pageNumber", label: "페이지번호", icon: <Hash className="h-4 w-4" /> },
|
||||
{ type: "card", label: "정보카드", icon: <CreditCard className="h-4 w-4" /> },
|
||||
{ type: "calculation", label: "계산", icon: <Calculator className="h-4 w-4" /> },
|
||||
{ type: "barcode", label: "바코드/QR", icon: <Barcode className="h-4 w-4" /> },
|
||||
{ type: "checkbox", label: "체크박스", icon: <CheckSquare className="h-4 w-4" /> },
|
||||
interface ComponentCategory {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
items: ComponentItem[];
|
||||
}
|
||||
|
||||
const CATEGORIES: ComponentCategory[] = [
|
||||
{
|
||||
id: "basic",
|
||||
label: "기본 요소",
|
||||
icon: <LayoutGrid className="h-5 w-5" />,
|
||||
items: [
|
||||
{ type: "text", label: "텍스트", icon: <Type className="h-5 w-5" /> },
|
||||
{ type: "image", label: "이미지", icon: <Image className="h-5 w-5" /> },
|
||||
{ type: "divider", label: "구분선", icon: <Minus className="h-5 w-5" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
label: "데이터 표시",
|
||||
icon: <Database className="h-5 w-5" />,
|
||||
items: [
|
||||
{ type: "table", label: "테이블", icon: <Table className="h-5 w-5" /> },
|
||||
{ type: "card", label: "정보카드", icon: <CreditCard className="h-5 w-5" /> },
|
||||
{ type: "calculation", label: "계산", icon: <Calculator className="h-5 w-5" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "form",
|
||||
label: "입력/인증",
|
||||
icon: <FileText className="h-5 w-5" />,
|
||||
items: [
|
||||
{ type: "checkbox", label: "체크박스", icon: <CheckSquare className="h-5 w-5" /> },
|
||||
{ type: "signature", label: "서명란", icon: <PenLine className="h-5 w-5" /> },
|
||||
{ type: "stamp", label: "도장란", icon: <StampIcon className="h-5 w-5" /> },
|
||||
{ type: "barcode", label: "바코드/QR", icon: <Barcode className="h-5 w-5" /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function DraggableComponentItem({ type, label, icon }: ComponentItem) {
|
||||
@@ -34,23 +78,60 @@ function DraggableComponentItem({ type, label, icon }: ComponentItem) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drag}
|
||||
className={`flex cursor-move items-center gap-2 rounded border p-2 text-sm transition-all hover:border-primary hover:bg-primary/10 ${
|
||||
ref={drag as any}
|
||||
className={`flex h-20 cursor-move flex-col items-center justify-center gap-1.5 rounded-lg border border-gray-200 bg-gray-50 transition-colors hover:border-indigo-400 hover:bg-indigo-50 ${
|
||||
isDragging ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
<div className="text-gray-600">{icon}</div>
|
||||
<span className="text-xs font-medium text-gray-700">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComponentPalette() {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(CATEGORIES.map((c) => c.id)),
|
||||
);
|
||||
|
||||
const toggleCategory = (id: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{COMPONENTS.map((component) => (
|
||||
<DraggableComponentItem key={component.type} {...component} />
|
||||
<div className="space-y-1">
|
||||
{CATEGORIES.map((category) => {
|
||||
const isExpanded = expandedCategories.has(category.id);
|
||||
return (
|
||||
<div key={category.id}>
|
||||
<button
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-2 py-2 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-100"
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 text-gray-400 transition-transform ${isExpanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
<span className="text-gray-500">{category.icon}</span>
|
||||
{category.label}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="grid grid-cols-2 gap-3 px-1 pt-2 pb-3">
|
||||
{category.items.map((item) => (
|
||||
<DraggableComponentItem key={item.type} {...item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function GridSettingsPanel() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">셀 너비</Label>
|
||||
<span className="text-xs text-muted-foreground">{gridConfig.cellWidth}px</span>
|
||||
<span className="text-xs text-gray-500">{gridConfig.cellWidth}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[gridConfig.cellWidth]}
|
||||
@@ -73,7 +73,7 @@ export function GridSettingsPanel() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">셀 높이</Label>
|
||||
<span className="text-xs text-muted-foreground">{gridConfig.cellHeight}px</span>
|
||||
<span className="text-xs text-gray-500">{gridConfig.cellHeight}px</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[gridConfig.cellHeight]}
|
||||
@@ -89,7 +89,7 @@ export function GridSettingsPanel() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">투명도</Label>
|
||||
<span className="text-xs text-muted-foreground">{Math.round(gridConfig.gridOpacity * 100)}%</span>
|
||||
<span className="text-xs text-gray-500">{Math.round(gridConfig.gridOpacity * 100)}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[gridConfig.gridOpacity * 100]}
|
||||
@@ -122,7 +122,7 @@ export function GridSettingsPanel() {
|
||||
</div>
|
||||
|
||||
{/* 그리드 정보 */}
|
||||
<div className="rounded border bg-muted p-2 text-xs text-muted-foreground">
|
||||
<div className="rounded border bg-gray-50 p-2 text-xs text-gray-600">
|
||||
<div className="flex justify-between">
|
||||
<span>행:</span>
|
||||
<span className="font-mono">{gridConfig.rows}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -25,7 +26,6 @@ interface MenuSelectModalProps {
|
||||
selectedMenuObjids?: number[];
|
||||
}
|
||||
|
||||
// 트리 구조의 메뉴 노드
|
||||
interface MenuTreeNode {
|
||||
objid: string;
|
||||
menuNameKor: string;
|
||||
@@ -35,26 +35,32 @@ interface MenuTreeNode {
|
||||
parentObjId: string;
|
||||
}
|
||||
|
||||
export function MenuSelectModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
selectedMenuObjids = [],
|
||||
}: MenuSelectModalProps) {
|
||||
export function MenuSelectModal({ isOpen, onClose, onConfirm, selectedMenuObjids = [] }: MenuSelectModalProps) {
|
||||
const [menus, setMenus] = useState<MenuItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set(selectedMenuObjids));
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
const initialSelectionRef = useRef<string>("");
|
||||
|
||||
const hasChanges = useCallback(() => {
|
||||
const currentSelection = JSON.stringify(Array.from(selectedIds).sort());
|
||||
return currentSelection !== initialSelectionRef.current;
|
||||
}, [selectedIds]);
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
hasChanges,
|
||||
onClose,
|
||||
description: "변경된 선택 내용이 저장되지 않습니다. 정말 닫으시겠습니까?",
|
||||
});
|
||||
|
||||
// 초기 선택 상태 동기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedIds(new Set(selectedMenuObjids));
|
||||
initialSelectionRef.current = JSON.stringify(Array.from(new Set(selectedMenuObjids)).sort());
|
||||
}
|
||||
}, [isOpen, selectedMenuObjids]);
|
||||
|
||||
// 메뉴 목록 로드
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchMenus();
|
||||
@@ -66,10 +72,9 @@ export function MenuSelectModal({
|
||||
try {
|
||||
const response = await menuApi.getUserMenus();
|
||||
if (response.success && response.data) {
|
||||
setMenus(response.data);
|
||||
// 처음 2레벨까지 자동 확장
|
||||
setMenus(response.data as MenuItem[]);
|
||||
const initialExpanded = new Set<string>();
|
||||
response.data.forEach((menu) => {
|
||||
(response.data as MenuItem[]).forEach((menu: any) => {
|
||||
const level = menu.lev || menu.LEV || 1;
|
||||
if (level <= 2) {
|
||||
initialExpanded.add(menu.objid || menu.OBJID || "");
|
||||
@@ -84,12 +89,10 @@ export function MenuSelectModal({
|
||||
}
|
||||
};
|
||||
|
||||
// 메뉴 트리 구조 생성
|
||||
const menuTree = useMemo(() => {
|
||||
const menuMap = new Map<string, MenuTreeNode>();
|
||||
const rootMenus: MenuTreeNode[] = [];
|
||||
|
||||
// 모든 메뉴를 노드로 변환
|
||||
menus.forEach((menu) => {
|
||||
const objid = menu.objid || menu.OBJID || "";
|
||||
const parentObjId = menu.parentObjId || menu.PARENT_OBJ_ID || "";
|
||||
@@ -97,25 +100,15 @@ export function MenuSelectModal({
|
||||
const menuUrl = menu.menuUrl || menu.MENU_URL || "";
|
||||
const level = menu.lev || menu.LEV || 1;
|
||||
|
||||
menuMap.set(objid, {
|
||||
objid,
|
||||
menuNameKor,
|
||||
menuUrl,
|
||||
level,
|
||||
children: [],
|
||||
parentObjId,
|
||||
});
|
||||
menuMap.set(objid, { objid, menuNameKor, menuUrl, level, children: [], parentObjId });
|
||||
});
|
||||
|
||||
// 부모-자식 관계 설정
|
||||
menus.forEach((menu) => {
|
||||
const objid = menu.objid || menu.OBJID || "";
|
||||
const parentObjId = menu.parentObjId || menu.PARENT_OBJ_ID || "";
|
||||
const node = menuMap.get(objid);
|
||||
|
||||
if (!node) return;
|
||||
|
||||
// 최상위 메뉴인지 확인 (parent가 없거나, 특정 루트 ID)
|
||||
const parent = menuMap.get(parentObjId);
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
@@ -124,7 +117,6 @@ export function MenuSelectModal({
|
||||
}
|
||||
});
|
||||
|
||||
// 자식 메뉴 정렬
|
||||
const sortChildren = (nodes: MenuTreeNode[]) => {
|
||||
nodes.sort((a, b) => a.menuNameKor.localeCompare(b.menuNameKor, "ko"));
|
||||
nodes.forEach((node) => sortChildren(node.children));
|
||||
@@ -134,24 +126,18 @@ export function MenuSelectModal({
|
||||
return rootMenus;
|
||||
}, [menus]);
|
||||
|
||||
// 검색 필터링
|
||||
const filteredTree = useMemo(() => {
|
||||
if (!searchText.trim()) return menuTree;
|
||||
|
||||
const searchLower = searchText.toLowerCase();
|
||||
|
||||
// 검색어에 맞는 노드와 그 조상 노드를 포함
|
||||
const filterNodes = (nodes: MenuTreeNode[]): MenuTreeNode[] => {
|
||||
return nodes
|
||||
.map((node) => {
|
||||
const filteredChildren = filterNodes(node.children);
|
||||
const matches = node.menuNameKor.toLowerCase().includes(searchLower);
|
||||
|
||||
if (matches || filteredChildren.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
children: filteredChildren,
|
||||
};
|
||||
return { ...node, children: filteredChildren };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
@@ -161,7 +147,6 @@ export function MenuSelectModal({
|
||||
return filterNodes(menuTree);
|
||||
}, [menuTree, searchText]);
|
||||
|
||||
// 체크박스 토글
|
||||
const toggleSelect = useCallback((objid: string) => {
|
||||
const numericId = Number(objid);
|
||||
setSelectedIds((prev) => {
|
||||
@@ -175,7 +160,6 @@ export function MenuSelectModal({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 확장/축소 토글
|
||||
const toggleExpand = useCallback((objid: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -188,13 +172,11 @@ export function MenuSelectModal({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 확인 버튼 클릭
|
||||
const handleConfirm = () => {
|
||||
onConfirm(Array.from(selectedIds));
|
||||
onClose();
|
||||
};
|
||||
|
||||
// 메뉴 노드 렌더링
|
||||
const renderMenuNode = (node: MenuTreeNode, depth: number = 0) => {
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expandedIds.has(node.objid);
|
||||
@@ -204,13 +186,12 @@ export function MenuSelectModal({
|
||||
<div key={node.objid}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-muted/50 cursor-pointer",
|
||||
"hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5",
|
||||
isSelected && "bg-primary/10",
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 20 + 8}px` }}
|
||||
onClick={() => toggleSelect(node.objid)}
|
||||
>
|
||||
{/* 확장/축소 버튼 */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -218,54 +199,44 @@ export function MenuSelectModal({
|
||||
e.stopPropagation();
|
||||
toggleExpand(node.objid);
|
||||
}}
|
||||
className="p-0.5 hover:bg-muted rounded"
|
||||
className="hover:bg-muted rounded p-0.5"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
<ChevronDown className="text-muted-foreground h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
<ChevronRight className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-5" />
|
||||
)}
|
||||
|
||||
{/* 체크박스 - 모든 메뉴에서 선택 가능 */}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelect(node.objid)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* 아이콘 */}
|
||||
{hasChildren ? (
|
||||
<FolderOpen className="h-4 w-4 text-amber-500" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<FileText className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
|
||||
{/* 메뉴명 */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm flex-1 truncate",
|
||||
isSelected && "font-medium text-primary",
|
||||
)}
|
||||
>
|
||||
<span className={cn("flex-1 truncate text-sm", isSelected && "text-primary font-medium")}>
|
||||
{node.menuNameKor}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 자식 메뉴 */}
|
||||
{hasChildren && isExpanded && (
|
||||
<div>{node.children.map((child) => renderMenuNode(child, depth + 1))}</div>
|
||||
)}
|
||||
{hasChildren && isExpanded && <div>{node.children.map((child) => renderMenuNode(child, depth + 1))}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[600px] max-h-[80vh] flex flex-col">
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={guard.handleOpenChange}>
|
||||
<DialogContent className="flex max-h-[80vh] max-w-[600px] flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>사용 메뉴 선택</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -273,9 +244,8 @@ export function MenuSelectModal({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="메뉴 검색..."
|
||||
value={searchText}
|
||||
@@ -284,20 +254,16 @@ export function MenuSelectModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 선택된 메뉴 수 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{selectedIds.size}개 메뉴 선택됨
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">{selectedIds.size}개 메뉴 선택됨</div>
|
||||
|
||||
{/* 메뉴 트리 */}
|
||||
<ScrollArea className="flex-1 border rounded-md">
|
||||
<ScrollArea className="flex-1 rounded-md border">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">메뉴 로드 중...</span>
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">메뉴 로드 중...</span>
|
||||
</div>
|
||||
) : filteredTree.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center justify-center py-8 text-sm">
|
||||
{searchText ? "검색 결과가 없습니다." : "표시할 메뉴가 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
@@ -306,15 +272,15 @@ export function MenuSelectModal({
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<Button variant="outline" onClick={guard.tryClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>
|
||||
확인 ({selectedIds.size}개 선택)
|
||||
</Button>
|
||||
<Button onClick={handleConfirm}>확인 ({selectedIds.size}개 선택)</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<UnsavedChangesDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { Plus, Copy, Trash2, GripVertical, Edit2, Check, X } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Plus, Copy, Trash2, Edit2, Check, X, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { MM_TO_PX } from "@/lib/report/constants";
|
||||
import type { ComponentConfig, ReportPage } from "@/types/report";
|
||||
|
||||
const THUMB_W = 80;
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
text: "#6366f1",
|
||||
label: "#6366f1",
|
||||
table: "#0891b2",
|
||||
image: "#16a34a",
|
||||
divider: "#9ca3af",
|
||||
signature: "#d97706",
|
||||
stamp: "#d97706",
|
||||
pageNumber: "#8b5cf6",
|
||||
card: "#0ea5e9",
|
||||
calculation: "#ec4899",
|
||||
barcode: "#1e293b",
|
||||
checkbox: "#f59e0b",
|
||||
};
|
||||
|
||||
function PageThumbnail({ page }: { page: ReportPage }) {
|
||||
const canvasW = page.width * MM_TO_PX;
|
||||
const canvasH = page.height * MM_TO_PX;
|
||||
const scale = THUMB_W / canvasW;
|
||||
const thumbH = canvasH * scale;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded border border-gray-200 bg-white shadow-sm"
|
||||
style={{ width: THUMB_W, height: thumbH }}
|
||||
>
|
||||
{page.components.map((comp: ComponentConfig) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="absolute rounded-[1px]"
|
||||
style={{
|
||||
left: comp.x * scale,
|
||||
top: comp.y * scale,
|
||||
width: Math.max(comp.width * scale, 2),
|
||||
height: Math.max(comp.height * scale, 2),
|
||||
backgroundColor: TYPE_COLORS[comp.type] ?? "#94a3b8",
|
||||
opacity: 0.6,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageListPanel() {
|
||||
const {
|
||||
@@ -24,11 +65,14 @@ export function PageListPanel() {
|
||||
reorderPages,
|
||||
selectPage,
|
||||
updatePageSettings,
|
||||
isPageListCollapsed,
|
||||
setIsPageListCollapsed,
|
||||
} = useReportDesigner();
|
||||
|
||||
const [editingPageId, setEditingPageId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dropTargetIndex, setDropTargetIndex] = useState<number | null>(null);
|
||||
|
||||
const handleStartEdit = (pageId: string, currentName: string) => {
|
||||
setEditingPageId(pageId);
|
||||
@@ -48,80 +92,144 @@ export function PageListPanel() {
|
||||
setEditingName("");
|
||||
};
|
||||
|
||||
const handleDragStart = (index: number) => {
|
||||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||
setDraggedIndex(index);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex === null || draggedIndex === index) return;
|
||||
|
||||
// 실시간으로 순서 변경하지 않고, drop 시에만 변경
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (draggedIndex === null || draggedIndex === index) {
|
||||
setDropTargetIndex(null);
|
||||
return;
|
||||
}
|
||||
setDropTargetIndex(index);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetIndex: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (draggedIndex === null) return;
|
||||
|
||||
const sourceIndex = draggedIndex;
|
||||
if (sourceIndex !== targetIndex) {
|
||||
reorderPages(sourceIndex, targetIndex);
|
||||
if (draggedIndex !== targetIndex) {
|
||||
reorderPages(draggedIndex, targetIndex);
|
||||
}
|
||||
|
||||
setDraggedIndex(null);
|
||||
setDropTargetIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
setDropTargetIndex(null);
|
||||
};
|
||||
|
||||
if (isPageListCollapsed) {
|
||||
return (
|
||||
<div className="bg-background flex h-full w-32 flex-col border-r">
|
||||
<div className="flex h-full w-10 shrink-0 flex-col items-center border-r border-gray-200 bg-white pt-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setIsPageListCollapsed(false)}
|
||||
title="페이지 목록 열기"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-40 shrink-0 flex-col border-r border-gray-200 bg-white">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b px-2 py-1.5">
|
||||
<h3 className="text-[10px] font-semibold">페이지</h3>
|
||||
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={() => addPage()}>
|
||||
<Plus className="h-3 w-3" />
|
||||
<div className="flex h-11 items-center justify-between border-b border-gray-200 px-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setIsPageListCollapsed(true)}
|
||||
title="페이지 목록 접기"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-bold text-gray-800">페이지</span>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => addPage()} title="페이지 추가">
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 페이지 목록 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full p-1">
|
||||
<div className="space-y-1">
|
||||
<ScrollArea className="h-full p-2">
|
||||
<div className="space-y-2" onDragOver={(e) => e.preventDefault()}>
|
||||
{layoutConfig.pages
|
||||
.sort((a, b) => a.page_order - b.page_order)
|
||||
.map((page, index) => (
|
||||
<div
|
||||
key={page.page_id}
|
||||
className={`group relative cursor-pointer rounded border p-1.5 transition-all ${
|
||||
page.page_id === currentPageId
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border hover:border-primary/50 hover:bg-accent/50"
|
||||
} ${draggedIndex === index ? "opacity-50" : ""}`}
|
||||
onClick={() => selectPage(page.page_id)}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
className={`group relative cursor-grab rounded-md border px-2 pt-1 pb-2 transition-all hover:shadow active:cursor-grabbing ${
|
||||
page.page_id === currentPageId
|
||||
? "border-indigo-400 bg-indigo-50 shadow-md ring-1 ring-indigo-200"
|
||||
: "border-gray-100 bg-white hover:border-gray-200"
|
||||
} ${draggedIndex === index ? "opacity-30" : ""} ${dropTargetIndex === index ? "border-dashed border-blue-400" : ""}`}
|
||||
onClick={() => selectPage(page.page_id)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 드래그 핸들 */}
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
{/* 상단 우측: 액션 버튼 모음 */}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-4 w-4 p-0 text-gray-400 hover:text-blue-600"
|
||||
title="이름 변경"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDragStart(index);
|
||||
handleStartEdit(page.page_id, page.page_name);
|
||||
}}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="text-muted-foreground cursor-grab opacity-0 transition-opacity group-hover:opacity-100 active:cursor-grabbing"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
<Edit2 className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-4 w-4 p-0 text-gray-400 hover:text-blue-600"
|
||||
title="복제"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicatePage(page.page_id);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-4 w-4 p-0 text-gray-400 hover:text-red-500"
|
||||
title="삭제"
|
||||
disabled={layoutConfig.pages.length <= 1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deletePage(page.page_id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 페이지 정보 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* 썸네일 + 제목 (하단부 배치) */}
|
||||
<div className="mt-4 flex flex-col items-center">
|
||||
<PageThumbnail page={page} />
|
||||
<div className="mt-2 w-full text-center">
|
||||
{editingPageId === page.page_id ? (
|
||||
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Input
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
@@ -129,81 +237,36 @@ export function PageListPanel() {
|
||||
if (e.key === "Enter") handleSaveEdit();
|
||||
if (e.key === "Escape") handleCancelEdit();
|
||||
}}
|
||||
className="h-5 text-[10px]"
|
||||
className="h-6 w-full px-1 text-center text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="sm" variant="ghost" className="h-4 w-4 p-0" onClick={handleSaveEdit}>
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
<Button size="sm" variant="ghost" className="h-5 w-5 shrink-0 p-0 text-blue-600 hover:text-blue-700" onClick={handleSaveEdit}>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-4 w-4 p-0" onClick={handleCancelEdit}>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
<Button size="sm" variant="ghost" className="h-5 w-5 shrink-0 p-0 text-red-500 hover:text-red-600" onClick={handleCancelEdit}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="truncate text-[10px] font-medium">{page.page_name}</div>
|
||||
)}
|
||||
<div className="text-muted-foreground text-[8px]">
|
||||
{page.width}x{page.height}mm
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 메뉴 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-4 w-4 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<span className="sr-only">메뉴</span>
|
||||
<span className="text-[10px] leading-none">⋮</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
<div
|
||||
className="cursor-text"
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartEdit(page.page_id, page.page_name);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-3 w-3" />
|
||||
이름 변경
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicatePage(page.page_id);
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-2 h-3 w-3" />
|
||||
복제
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deletePage(page.page_id);
|
||||
}}
|
||||
disabled={layoutConfig.pages.length <= 1}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-3 w-3" />
|
||||
삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="block text-[11px] font-semibold text-gray-700">
|
||||
{index + 1}. {page.page_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="border-t p-1">
|
||||
<Button size="sm" variant="outline" className="h-6 w-full text-[10px]" onClick={() => addPage()}>
|
||||
<Plus className="mr-1 h-3 w-3" />추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Loader2, Trash2, Upload, ChevronRight, FileText, Maximize2, Space, Droplet } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export function PageSettingsTab() {
|
||||
const { currentPage, currentPageId, updatePageSettings, layoutConfig, updateWatermark } = useReportDesigner();
|
||||
const [uploadingWatermarkImage, setUploadingWatermarkImage] = useState(false);
|
||||
const watermarkFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [expandedSection, setExpandedSection] = useState<string>("page-info");
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleWatermarkImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast({ title: "오류", description: "이미지 파일만 업로드 가능합니다.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({ title: "오류", description: "파일 크기는 5MB 이하여야 합니다.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploadingWatermarkImage(true);
|
||||
const result = await reportApi.uploadImage(file);
|
||||
if (result.success) {
|
||||
updateWatermark({ ...layoutConfig.watermark!, imageUrl: result.data.fileUrl });
|
||||
toast({ title: "성공", description: "워터마크 이미지가 업로드되었습니다." });
|
||||
}
|
||||
} catch {
|
||||
toast({ title: "오류", description: "이미지 업로드 중 오류가 발생했습니다.", variant: "destructive" });
|
||||
} finally {
|
||||
setUploadingWatermarkImage(false);
|
||||
if (watermarkFileInputRef.current) watermarkFileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentPage || !currentPageId) {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">페이지를 선택하세요</p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleSection = (id: string) => {
|
||||
setExpandedSection(expandedSection === id ? "" : id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-white">
|
||||
{/* 페이지 정보 (아코디언) */}
|
||||
<div className="border-b-2 border-gray-100">
|
||||
<button
|
||||
onClick={() => toggleSection("page-info")}
|
||||
className={`flex h-12 w-full items-center justify-between px-4 transition-colors ${
|
||||
expandedSection === "page-info"
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className={`h-4 w-4 ${expandedSection === "page-info" ? "" : "text-blue-600"}`} />
|
||||
<span className="text-sm font-bold">페이지 정보</span>
|
||||
</div>
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${expandedSection === "page-info" ? "rotate-90" : expandedSection === "page-info" ? "" : "text-gray-400"}`} />
|
||||
</button>
|
||||
{expandedSection === "page-info" && (
|
||||
<div className="space-y-4 border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-700">페이지명</Label>
|
||||
<Input
|
||||
value={currentPage.page_name}
|
||||
onChange={(e) => updatePageSettings(currentPageId, { page_name: e.target.value })}
|
||||
className="h-10 border-2 text-sm focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이지 크기 (아코디언) */}
|
||||
<div className="border-b-2 border-gray-100">
|
||||
<button
|
||||
onClick={() => toggleSection("page-size")}
|
||||
className={`flex h-12 w-full items-center justify-between px-4 transition-colors ${
|
||||
expandedSection === "page-size"
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Maximize2 className={`h-4 w-4 ${expandedSection === "page-size" ? "" : "text-blue-600"}`} />
|
||||
<span className="text-sm font-bold">페이지 크기</span>
|
||||
</div>
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${expandedSection === "page-size" ? "rotate-90" : "text-gray-400"}`} />
|
||||
</button>
|
||||
{expandedSection === "page-size" && (
|
||||
<div className="space-y-4 border-t border-gray-100 bg-gray-50/50 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-700">방향</Label>
|
||||
<Select
|
||||
value={currentPage.orientation}
|
||||
onValueChange={(value: "portrait" | "landscape") => updatePageSettings(currentPageId, { orientation: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10 border-2 text-sm focus:border-blue-500">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="portrait">세로</SelectItem>
|
||||
<SelectItem value="landscape">가로</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-700">너비 (mm)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={currentPage.width}
|
||||
onChange={(e) => updatePageSettings(currentPageId, { width: Math.max(1, Number(e.target.value)) })}
|
||||
className="h-10 border-2 text-sm focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-700">높이 (mm)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={currentPage.height}
|
||||
onChange={(e) => updatePageSettings(currentPageId, { height: Math.max(1, Number(e.target.value)) })}
|
||||
className="h-10 border-2 text-sm focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={() => updatePageSettings(currentPageId, { width: 210, height: 297, orientation: "portrait" })}>
|
||||
A4 세로
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-8 text-xs" onClick={() => updatePageSettings(currentPageId, { width: 297, height: 210, orientation: "landscape" })}>
|
||||
A4 가로
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 여백 설정 (아코디언) */}
|
||||
<div className="border-b-2 border-gray-100">
|
||||
<button
|
||||
onClick={() => toggleSection("margin")}
|
||||
className={`flex h-12 w-full items-center justify-between px-4 transition-colors ${
|
||||
expandedSection === "margin"
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Space className={`h-4 w-4 ${expandedSection === "margin" ? "" : "text-blue-600"}`} />
|
||||
<span className="text-sm font-bold">여백 설정</span>
|
||||
</div>
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${expandedSection === "margin" ? "rotate-90" : "text-gray-400"}`} />
|
||||
</button>
|
||||
{expandedSection === "margin" && (
|
||||
<div className="space-y-3 border-t border-gray-100 bg-gray-50/50 p-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(["top", "bottom", "left", "right"] as const).map((side) => (
|
||||
<div key={side} className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-700">
|
||||
{side === "top" ? "상단 (mm)" : side === "bottom" ? "하단 (mm)" : side === "left" ? "좌측 (mm)" : "우측 (mm)"}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={currentPage.margins[side]}
|
||||
onChange={(e) =>
|
||||
updatePageSettings(currentPageId, {
|
||||
margins: { ...currentPage.margins, [side]: Math.max(0, Number(e.target.value)) },
|
||||
})
|
||||
}
|
||||
className="h-10 border-2 text-sm focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 pt-1">
|
||||
{[
|
||||
{ label: "좁게", value: 10 },
|
||||
{ label: "보통", value: 20 },
|
||||
{ label: "넓게", value: 30 },
|
||||
].map(({ label, value }) => (
|
||||
<Button
|
||||
key={label}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => updatePageSettings(currentPageId, { margins: { top: value, bottom: value, left: value, right: value } })}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 워터마크 설정 (아코디언) */}
|
||||
<div className="border-b-2 border-gray-100">
|
||||
<button
|
||||
onClick={() => toggleSection("watermark")}
|
||||
className={`flex h-12 w-full items-center justify-between px-4 transition-colors ${
|
||||
expandedSection === "watermark"
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Droplet className={`h-4 w-4 ${expandedSection === "watermark" ? "" : "text-blue-600"}`} />
|
||||
<span className="text-sm font-bold">워터마크 설정</span>
|
||||
</div>
|
||||
<ChevronRight className={`h-4 w-4 transition-transform ${expandedSection === "watermark" ? "rotate-90" : "text-gray-400"}`} />
|
||||
</button>
|
||||
{expandedSection === "watermark" && (
|
||||
<div className="space-y-4 border-t border-gray-100 bg-gray-50/50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold text-gray-700">워터마크 사용</Label>
|
||||
<Switch
|
||||
checked={layoutConfig.watermark?.enabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark,
|
||||
enabled: checked,
|
||||
type: layoutConfig.watermark?.type ?? "text",
|
||||
opacity: layoutConfig.watermark?.opacity ?? 0.3,
|
||||
style: layoutConfig.watermark?.style ?? "diagonal",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{layoutConfig.watermark?.enabled && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-700">타입</Label>
|
||||
<Select
|
||||
value={layoutConfig.watermark?.type ?? "text"}
|
||||
onValueChange={(value: "text" | "image") => updateWatermark({ ...layoutConfig.watermark!, type: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10 border-2 text-sm focus:border-blue-500">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">텍스트</SelectItem>
|
||||
<SelectItem value="image">이미지</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{layoutConfig.watermark?.type === "text" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-700">텍스트</Label>
|
||||
<Input
|
||||
value={layoutConfig.watermark?.text ?? ""}
|
||||
onChange={(e) => updateWatermark({ ...layoutConfig.watermark!, text: e.target.value })}
|
||||
placeholder="CONFIDENTIAL"
|
||||
className="h-10 border-2 text-sm focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-700">폰트 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={layoutConfig.watermark?.fontSize ?? 48}
|
||||
onChange={(e) => updateWatermark({ ...layoutConfig.watermark!, fontSize: Number(e.target.value) })}
|
||||
className="h-10 border-2 text-sm focus:border-blue-500"
|
||||
min={12}
|
||||
max={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-700">색상</Label>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
type="color"
|
||||
value={layoutConfig.watermark?.fontColor ?? "#cccccc"}
|
||||
onChange={(e) => updateWatermark({ ...layoutConfig.watermark!, fontColor: e.target.value })}
|
||||
className="h-10 w-12 cursor-pointer p-1"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={layoutConfig.watermark?.fontColor ?? "#cccccc"}
|
||||
onChange={(e) => updateWatermark({ ...layoutConfig.watermark!, fontColor: e.target.value })}
|
||||
className="h-10 flex-1 border-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{layoutConfig.watermark?.type === "image" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-700">워터마크 이미지</Label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={watermarkFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleWatermarkImageUpload}
|
||||
className="hidden"
|
||||
disabled={uploadingWatermarkImage}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => watermarkFileInputRef.current?.click()}
|
||||
disabled={uploadingWatermarkImage}
|
||||
className="h-10 flex-1 border-2 text-sm"
|
||||
>
|
||||
{uploadingWatermarkImage ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Upload className="mr-2 h-4 w-4" />}
|
||||
{layoutConfig.watermark?.imageUrl ? "이미지 변경" : "이미지 선택"}
|
||||
</Button>
|
||||
{layoutConfig.watermark?.imageUrl && (
|
||||
<Button type="button" variant="ghost" size="sm" className="h-10" onClick={() => updateWatermark({ ...layoutConfig.watermark!, imageUrl: "" })}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-700">배치 스타일</Label>
|
||||
<Select
|
||||
value={layoutConfig.watermark?.style ?? "diagonal"}
|
||||
onValueChange={(value: "diagonal" | "center" | "tile") => updateWatermark({ ...layoutConfig.watermark!, style: value })}
|
||||
>
|
||||
<SelectTrigger className="h-10 border-2 text-sm focus:border-blue-500">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="diagonal">대각선</SelectItem>
|
||||
<SelectItem value="center">중앙</SelectItem>
|
||||
<SelectItem value="tile">타일 (반복)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{(layoutConfig.watermark?.style === "diagonal" || layoutConfig.watermark?.style === "tile") && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-semibold text-gray-700">회전 각도</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={layoutConfig.watermark?.rotation ?? -45}
|
||||
onChange={(e) => updateWatermark({ ...layoutConfig.watermark!, rotation: Number(e.target.value) })}
|
||||
className="h-10 border-2 text-sm focus:border-blue-500"
|
||||
min={-180}
|
||||
max={180}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold text-gray-700">투명도</Label>
|
||||
<span className="text-xs text-gray-500">{Math.round((layoutConfig.watermark?.opacity ?? 0.3) * 100)}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[(layoutConfig.watermark?.opacity ?? 0.3) * 100]}
|
||||
onValueChange={(value) => updateWatermark({ ...layoutConfig.watermark!, opacity: value[0] / 100 })}
|
||||
min={5}
|
||||
max={100}
|
||||
step={5}
|
||||
className="my-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
{[
|
||||
{ label: "초안", text: "DRAFT", fontSize: 64, fontColor: "#cccccc", style: "diagonal" as const, opacity: 0.2, rotation: -45 },
|
||||
{ label: "대외비", text: "대외비", fontSize: 64, fontColor: "#ff0000", style: "diagonal" as const, opacity: 0.15, rotation: -45 },
|
||||
{ label: "샘플", text: "SAMPLE", fontSize: 48, fontColor: "#888888", style: "tile" as const, opacity: 0.1, rotation: -30 },
|
||||
{ label: "사본", text: "COPY", fontSize: 56, fontColor: "#aaaaaa", style: "center" as const, opacity: 0.25 },
|
||||
].map(({ label, ...preset }) => (
|
||||
<Button
|
||||
key={label}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => updateWatermark({ ...layoutConfig.watermark!, type: "text", ...preset })}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -264,7 +264,7 @@ export function QueryManager() {
|
||||
const queryParamTypes = parameterTypes[query.id] || {};
|
||||
|
||||
return (
|
||||
<AccordionItem key={query.id} value={query.id} className="border-b border-border">
|
||||
<AccordionItem key={query.id} value={query.id} className="border-b border-gray-200">
|
||||
<div className="flex items-center gap-1">
|
||||
<AccordionTrigger className="flex-1 px-0 py-2.5 hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -280,7 +280,7 @@ export function QueryManager() {
|
||||
onClick={(e) => handleDeleteQuery(query.id, e)}
|
||||
className="h-7 w-7 shrink-0 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
<AccordionContent className="space-y-4 pt-1 pr-0 pb-3 pl-0">
|
||||
@@ -339,7 +339,7 @@ export function QueryManager() {
|
||||
</SelectItem>
|
||||
{externalConnections.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">외부 DB</div>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500">외부 DB</div>
|
||||
{externalConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -369,9 +369,9 @@ export function QueryManager() {
|
||||
|
||||
{/* 파라미터 입력 */}
|
||||
{query.parameters.length > 0 && (
|
||||
<div className="space-y-3 rounded-md border border-amber-200 bg-amber-50 p-3">
|
||||
<div className="space-y-3 rounded-md border border-yellow-200 bg-yellow-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-amber-600" />
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600" />
|
||||
<Label className="text-xs font-semibold text-yellow-800">파라미터</Label>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -435,7 +435,7 @@ export function QueryManager() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="w-full bg-destructive hover:bg-destructive"
|
||||
className="w-full bg-red-500 hover:bg-red-600"
|
||||
onClick={() => handleTestQuery(query)}
|
||||
disabled={!queryValidation.isValid || isTestRunning[query.id] || !isAllParametersFilled(query)}
|
||||
>
|
||||
@@ -445,8 +445,8 @@ export function QueryManager() {
|
||||
|
||||
{/* 결과 필드 */}
|
||||
{testResult && (
|
||||
<div className="space-y-2 rounded-md border border-emerald-200 bg-emerald-50 p-3">
|
||||
<Label className="text-xs font-semibold text-emerald-800">결과 필드</Label>
|
||||
<div className="space-y-2 rounded-md border border-green-200 bg-green-50 p-3">
|
||||
<Label className="text-xs font-semibold text-green-800">결과 필드</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{testResult.fields.map((field) => (
|
||||
<Badge key={field} variant="default" className="bg-teal-500">
|
||||
@@ -454,7 +454,7 @@ export function QueryManager() {
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-emerald-700">{testResult.rows.length}건의 데이터가 조회되었습니다.</p>
|
||||
<p className="text-xs text-green-700">{testResult.rows.length}건의 데이터가 조회되었습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
@@ -464,8 +464,8 @@ export function QueryManager() {
|
||||
</Accordion>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Database className="mb-2 h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Database className="mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">
|
||||
쿼리를 추가하여 리포트에
|
||||
<br />
|
||||
데이터를 연결하세요
|
||||
|
||||
@@ -1,196 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { ComponentConfig, WatermarkConfig } from "@/types/report";
|
||||
import { ComponentConfig } from "@/types/report";
|
||||
import { CanvasComponent } from "./CanvasComponent";
|
||||
import { Ruler } from "./Ruler";
|
||||
import { WatermarkLayer } from "./WatermarkLayer";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
|
||||
// mm를 px로 변환하는 고정 스케일 팩터 (화면 해상도와 무관하게 일정)
|
||||
// A4 기준: 210mm x 297mm → 840px x 1188px
|
||||
export const MM_TO_PX = 4;
|
||||
|
||||
// 워터마크 레이어 컴포넌트
|
||||
interface WatermarkLayerProps {
|
||||
watermark: WatermarkConfig;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
}
|
||||
|
||||
function WatermarkLayer({ watermark, canvasWidth, canvasHeight }: WatermarkLayerProps) {
|
||||
// 공통 스타일
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
zIndex: 1, // 컴포넌트보다 낮은 z-index
|
||||
};
|
||||
|
||||
// 대각선 스타일
|
||||
if (watermark.style === "diagonal") {
|
||||
const rotation = watermark.rotation ?? -45;
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 48}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={getFullImageUrl(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
maxWidth: "50%",
|
||||
maxHeight: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 중앙 스타일
|
||||
if (watermark.style === "center") {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 48}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={getFullImageUrl(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
maxWidth: "50%",
|
||||
maxHeight: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 타일 스타일
|
||||
if (watermark.style === "tile") {
|
||||
const rotation = watermark.rotation ?? -30;
|
||||
// 타일 간격 계산
|
||||
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
||||
const cols = Math.ceil(canvasWidth / tileSize) + 2;
|
||||
const rows = Math.ceil(canvasHeight / tileSize) + 2;
|
||||
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-50%",
|
||||
left: "-50%",
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignContent: "flex-start",
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: rows * cols }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
width: `${tileSize}px`,
|
||||
height: `${tileSize}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 24}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={getFullImageUrl(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
width: `${tileSize * 0.6}px`,
|
||||
height: `${tileSize * 0.6}px`,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
import { MousePointer } from "lucide-react";
|
||||
import { MM_TO_PX } from "@/lib/report/constants";
|
||||
|
||||
export function ReportDesignerCanvas() {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
currentPageId,
|
||||
currentPage,
|
||||
@@ -219,8 +42,29 @@ export function ReportDesignerCanvas() {
|
||||
redo,
|
||||
showRuler,
|
||||
layoutConfig,
|
||||
zoom,
|
||||
setZoom,
|
||||
fitTrigger,
|
||||
} = useReportDesigner();
|
||||
|
||||
// 캔버스 Auto-Fit: fitTrigger 변경 시 컨테이너에 맞춰 줌 재계산
|
||||
const calculateFitZoom = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
const rulerSpace = showRuler ? 20 : 0;
|
||||
const padding = 24; // p-3 × 2
|
||||
const availableWidth = containerRef.current.clientWidth - rulerSpace - padding;
|
||||
const availableHeight = containerRef.current.clientHeight - rulerSpace - padding;
|
||||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||||
if (availableWidth <= 0 || availableHeight <= 0 || canvasWidthPx <= 0 || canvasHeightPx <= 0) return;
|
||||
const newZoom = Math.min(availableWidth / canvasWidthPx, availableHeight / canvasHeightPx, 1);
|
||||
setZoom(Math.round(Math.max(0.1, newZoom) * 100) / 100);
|
||||
}, [showRuler, canvasWidth, canvasHeight, setZoom]);
|
||||
|
||||
useEffect(() => {
|
||||
calculateFitZoom();
|
||||
}, [fitTrigger, calculateFitZoom]);
|
||||
|
||||
// 드래그 영역 선택 (Marquee Selection) 상태
|
||||
const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false);
|
||||
const [marqueeStart, setMarqueeStart] = useState({ x: 0, y: 0 });
|
||||
@@ -247,8 +91,8 @@ export function ReportDesignerCanvas() {
|
||||
|
||||
if (!offset) return;
|
||||
|
||||
const x = offset.x - canvasRect.left;
|
||||
const y = offset.y - canvasRect.top;
|
||||
const x = (offset.x - canvasRect.left) / zoom;
|
||||
const y = (offset.y - canvasRect.top) / zoom;
|
||||
|
||||
// 컴포넌트 타입별 기본 설정
|
||||
let width = 200;
|
||||
@@ -325,6 +169,17 @@ export function ReportDesignerCanvas() {
|
||||
...(item.componentType === "image" && {
|
||||
imageUrl: "",
|
||||
objectFit: "contain" as const,
|
||||
imageOpacity: 1,
|
||||
imageBorderRadius: 0,
|
||||
imageCaption: "",
|
||||
imageCaptionPosition: "bottom" as const,
|
||||
imageCaptionFontSize: 12,
|
||||
imageCaptionColor: "#666666",
|
||||
imageCaptionAlign: "center" as const,
|
||||
imageAlt: "",
|
||||
imageRotation: 0,
|
||||
imageFlipH: false,
|
||||
imageFlipV: false,
|
||||
}),
|
||||
// 구분선 전용
|
||||
...(item.componentType === "divider" && {
|
||||
@@ -460,8 +315,8 @@ export function ReportDesignerCanvas() {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const x = (e.clientX - rect.left) / zoom;
|
||||
const y = (e.clientY - rect.top) / zoom;
|
||||
|
||||
// state와 ref 모두 설정
|
||||
setIsMarqueeSelecting(true);
|
||||
@@ -484,8 +339,8 @@ export function ReportDesignerCanvas() {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(e.clientX - rect.left, canvasWidth * MM_TO_PX));
|
||||
const y = Math.max(0, Math.min(e.clientY - rect.top, canvasHeight * MM_TO_PX));
|
||||
const x = Math.max(0, Math.min((e.clientX - rect.left) / zoom, canvasWidth * MM_TO_PX));
|
||||
const y = Math.max(0, Math.min((e.clientY - rect.top) / zoom, canvasHeight * MM_TO_PX));
|
||||
|
||||
// state와 ref 둘 다 업데이트
|
||||
setMarqueeEnd({ x, y });
|
||||
@@ -509,7 +364,7 @@ export function ReportDesignerCanvas() {
|
||||
|
||||
// 최소 드래그 거리 체크 (5px 이상이어야 선택으로 인식)
|
||||
const dragDistance = Math.sqrt(
|
||||
Math.pow(currentEnd.x - currentStart.x, 2) + Math.pow(currentEnd.y - currentStart.y, 2)
|
||||
Math.pow(currentEnd.x - currentStart.x, 2) + Math.pow(currentEnd.y - currentStart.y, 2),
|
||||
);
|
||||
|
||||
if (dragDistance > 5) {
|
||||
@@ -715,26 +570,26 @@ export function ReportDesignerCanvas() {
|
||||
// 페이지가 없는 경우
|
||||
if (!currentPageId || !currentPage) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-muted">
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-foreground">페이지가 없습니다</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">좌측에서 페이지를 추가하세요.</p>
|
||||
<h3 className="text-lg font-semibold text-gray-700">페이지가 없습니다</h3>
|
||||
<p className="mt-2 text-sm text-gray-500">좌측에서 페이지를 추가하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden bg-muted">
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden bg-gray-100">
|
||||
{/* 캔버스 스크롤 영역 */}
|
||||
<div className="flex flex-1 items-center justify-center overflow-auto px-8 pt-[280px] pb-8">
|
||||
{/* 눈금자와 캔버스를 감싸는 컨테이너 */}
|
||||
<div className="inline-flex flex-col">
|
||||
<div ref={containerRef} className="flex flex-1 justify-center overflow-auto">
|
||||
{/* 눈금자와 캔버스를 감싸는 컨테이너 (zoom 적용) */}
|
||||
<div className="inline-flex flex-col p-3" style={{ zoom }}>
|
||||
{/* 좌상단 코너 + 가로 눈금자 */}
|
||||
{showRuler && (
|
||||
<div className="flex">
|
||||
{/* 좌상단 코너 (20x20) */}
|
||||
<div className="h-5 w-5 bg-muted/80" />
|
||||
<div className="h-5 w-5 bg-gray-200" />
|
||||
{/* 가로 눈금자 */}
|
||||
<Ruler orientation="horizontal" length={canvasWidth} />
|
||||
</div>
|
||||
@@ -751,7 +606,7 @@ export function ReportDesignerCanvas() {
|
||||
canvasRef.current = node;
|
||||
drop(node);
|
||||
}}
|
||||
className={`relative bg-white shadow-lg ${isOver ? "ring-2 ring-ring" : ""}`}
|
||||
className={`relative bg-white shadow-[0_4px_24px_rgba(0,0,0,0.12)] ${isOver ? "ring-2 ring-blue-500" : ""}`}
|
||||
style={{
|
||||
width: `${canvasWidth * MM_TO_PX}px`,
|
||||
minHeight: `${canvasHeight * MM_TO_PX}px`,
|
||||
@@ -770,7 +625,7 @@ export function ReportDesignerCanvas() {
|
||||
{/* 페이지 여백 가이드 */}
|
||||
{currentPage && (
|
||||
<div
|
||||
className="pointer-events-none absolute border-2 border-dashed border-primary/40/50"
|
||||
className="pointer-events-none absolute border-2 border-dashed border-blue-300/50"
|
||||
style={{
|
||||
top: `${currentPage.margins.top * MM_TO_PX}px`,
|
||||
left: `${currentPage.margins.left * MM_TO_PX}px`,
|
||||
@@ -784,8 +639,8 @@ export function ReportDesignerCanvas() {
|
||||
{layoutConfig.watermark?.enabled && (
|
||||
<WatermarkLayer
|
||||
watermark={layoutConfig.watermark}
|
||||
canvasWidth={canvasWidth * MM_TO_PX}
|
||||
canvasHeight={canvasHeight * MM_TO_PX}
|
||||
width={canvasWidth * MM_TO_PX}
|
||||
height={canvasHeight * MM_TO_PX}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -823,7 +678,7 @@ export function ReportDesignerCanvas() {
|
||||
{/* 드래그 영역 선택 사각형 */}
|
||||
{isMarqueeSelecting && (
|
||||
<div
|
||||
className="pointer-events-none absolute border-2 border-primary bg-primary/10"
|
||||
className="pointer-events-none absolute border-2 border-blue-500 bg-blue-500/10"
|
||||
style={{
|
||||
left: `${getMarqueeRect().left}px`,
|
||||
top: `${getMarqueeRect().top}px`,
|
||||
@@ -836,8 +691,12 @@ export function ReportDesignerCanvas() {
|
||||
|
||||
{/* 빈 캔버스 안내 */}
|
||||
{components.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground/70">
|
||||
<p className="text-sm">왼쪽에서 컴포넌트를 드래그하여 추가하세요</p>
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<MousePointer className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
||||
<p className="mb-1 text-base text-gray-400">좌측 도구상자에서</p>
|
||||
<p className="text-base text-gray-400">컴포넌트를 끌어다 놓으세요</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useState } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronRight, Puzzle, FileText, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { ComponentPalette } from "./ComponentPalette";
|
||||
import { TemplatePalette } from "./TemplatePalette";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
|
||||
interface AccordionSection {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const SECTIONS: AccordionSection[] = [
|
||||
{ id: "templates", label: "기본 템플릿", icon: <FileText className="h-5 w-5" /> },
|
||||
{ id: "components", label: "컴포넌트", icon: <Puzzle className="h-5 w-5" /> },
|
||||
];
|
||||
|
||||
export function ReportDesignerLeftPanel() {
|
||||
return (
|
||||
<div className="w-80 border-r bg-white">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 템플릿 */}
|
||||
<Card className="border-2">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">기본 템플릿</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<TemplatePalette />
|
||||
</CardContent>
|
||||
</Card>
|
||||
const [expandedSection, setExpandedSection] = useState<string>("components");
|
||||
const { isLeftPanelCollapsed, setIsLeftPanelCollapsed } = useReportDesigner();
|
||||
|
||||
{/* 컴포넌트 */}
|
||||
<Card className="border-2">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">컴포넌트</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<ComponentPalette />
|
||||
</CardContent>
|
||||
</Card>
|
||||
const toggleSection = (id: string) => {
|
||||
setExpandedSection(expandedSection === id ? "" : id);
|
||||
};
|
||||
|
||||
if (isLeftPanelCollapsed) {
|
||||
return (
|
||||
<div className="flex h-full w-10 shrink-0 flex-col items-center border-r border-gray-200 bg-white pt-2 gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setIsLeftPanelCollapsed(false)}
|
||||
title="도구상자 열기"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
{SECTIONS.map((section) => (
|
||||
<Button
|
||||
key={section.id}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
title={section.label}
|
||||
onClick={() => {
|
||||
setIsLeftPanelCollapsed(false);
|
||||
setExpandedSection(section.id);
|
||||
}}
|
||||
>
|
||||
{section.icon}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-[260px] shrink-0 flex-col border-r border-gray-200 bg-white">
|
||||
<div className="flex h-11 items-center justify-between border-b border-gray-200 px-3">
|
||||
<span className="text-sm font-bold text-gray-800">도구상자</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => setIsLeftPanelCollapsed(true)}
|
||||
title="도구상자 접기"
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{SECTIONS.map((section) => {
|
||||
const isExpanded = expandedSection === section.id;
|
||||
return (
|
||||
<div key={section.id} className="border-b-2 border-gray-100">
|
||||
<button
|
||||
onClick={() => toggleSection(section.id)}
|
||||
className={`flex h-14 w-full items-center justify-between px-5 transition-colors ${
|
||||
isExpanded
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={isExpanded ? "" : "text-blue-600"}>{section.icon}</span>
|
||||
<span className="text-base font-bold">{section.label}</span>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className={`h-5 w-5 transition-transform ${isExpanded ? "rotate-90" : "text-gray-400"}`}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-5">
|
||||
{section.id === "templates" && <TemplatePalette />}
|
||||
{section.id === "components" && <ComponentPalette />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,10 +31,14 @@ import {
|
||||
Ruler as RulerIcon,
|
||||
Group,
|
||||
Ungroup,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -60,8 +64,10 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import { ReportPreviewModal } from "./ReportPreviewModal";
|
||||
|
||||
export function ReportDesignerToolbar() {
|
||||
const router = useRouter();
|
||||
const closeTab = useTabStore((s) => s.closeTab);
|
||||
const currentTabId = useTabId();
|
||||
const {
|
||||
reportId,
|
||||
reportDetail,
|
||||
saveLayoutWithMenus,
|
||||
isSaving,
|
||||
@@ -102,6 +108,12 @@ export function ReportDesignerToolbar() {
|
||||
groupComponents,
|
||||
ungroupComponents,
|
||||
menuObjids,
|
||||
zoom,
|
||||
setZoom,
|
||||
fitToScreen,
|
||||
isPageListCollapsed,
|
||||
isLeftPanelCollapsed,
|
||||
isRightPanelCollapsed,
|
||||
} = useReportDesigner();
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
|
||||
@@ -127,12 +139,27 @@ export function ReportDesignerToolbar() {
|
||||
setShowGrid(newValue);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const handleSave = async () => {
|
||||
if (reportId !== "new") {
|
||||
await saveLayoutWithMenus(menuObjids);
|
||||
return;
|
||||
}
|
||||
setPendingSaveAndClose(false);
|
||||
setShowMenuSelect(true);
|
||||
};
|
||||
|
||||
const handleSaveAndClose = () => {
|
||||
const closeDesignerTab = useCallback(() => {
|
||||
if (currentTabId) {
|
||||
closeTab(currentTabId);
|
||||
}
|
||||
}, [currentTabId, closeTab]);
|
||||
|
||||
const handleSaveAndClose = async () => {
|
||||
if (reportId !== "new") {
|
||||
await saveLayoutWithMenus(menuObjids);
|
||||
closeDesignerTab();
|
||||
return;
|
||||
}
|
||||
setPendingSaveAndClose(true);
|
||||
setShowMenuSelect(true);
|
||||
};
|
||||
@@ -140,7 +167,7 @@ export function ReportDesignerToolbar() {
|
||||
const handleMenuSelectConfirm = async (selectedMenuObjids: number[]) => {
|
||||
await saveLayoutWithMenus(selectedMenuObjids);
|
||||
if (pendingSaveAndClose) {
|
||||
router.push("/admin/screenMng/reportList");
|
||||
closeDesignerTab();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,7 +178,7 @@ export function ReportDesignerToolbar() {
|
||||
|
||||
const handleBackConfirm = () => {
|
||||
setShowBackConfirm(false);
|
||||
router.push("/admin/screenMng/reportList");
|
||||
closeDesignerTab();
|
||||
};
|
||||
|
||||
const handleSaveAsTemplate = async (data: {
|
||||
@@ -211,298 +238,245 @@ export function ReportDesignerToolbar() {
|
||||
}
|
||||
};
|
||||
|
||||
const leftToolbarWidth = (isPageListCollapsed ? 40 : 160) + (isLeftPanelCollapsed ? 40 : 260);
|
||||
const rightToolbarWidth = isRightPanelCollapsed ? 40 : 340;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b bg-white px-4 py-3 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowBackConfirm(true)} className="gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로
|
||||
<div className="flex h-14 items-center border-b border-gray-200 bg-white">
|
||||
{/* 좌측: 뒤로가기 + 제목 (패널 너비에 연동) */}
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 pl-3 transition-all duration-200"
|
||||
style={{ width: `${leftToolbarWidth}px` }}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => setShowBackConfirm(true)}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="h-6 w-px bg-muted/60" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate text-sm leading-tight font-bold text-gray-900 lg:text-lg">
|
||||
{reportDetail?.report.report_name_kor || "리포트 디자이너"}
|
||||
</h2>
|
||||
{reportDetail?.report.report_name_eng && (
|
||||
<p className="text-sm text-muted-foreground">{reportDetail.report.report_name_eng}</p>
|
||||
</h1>
|
||||
{reportDetail?.report.report_name_eng && !isLeftPanelCollapsed && (
|
||||
<p className="truncate text-xs leading-tight text-gray-500">{reportDetail.report.report_name_eng}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 중앙: 도구 그룹 (캔버스 영역에 맞춰 중앙 정렬) */}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-1 overflow-x-auto px-2 scrollbar-none lg:gap-2">
|
||||
{/* 뷰 도구 */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg bg-gray-50 px-1 py-1">
|
||||
<Button
|
||||
variant={snapToGrid && showGrid ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={handleToggleGrid}
|
||||
className="gap-2"
|
||||
title="Grid Snap 및 표시 켜기/끄기"
|
||||
>
|
||||
<Grid3x3 className="h-4 w-4" />
|
||||
{snapToGrid && showGrid ? "Grid ON" : "Grid OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant={showRuler ? "default" : "outline"}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-8 w-8 ${showRuler ? "bg-white shadow-sm" : ""}`}
|
||||
onClick={() => setShowRuler(!showRuler)}
|
||||
className="gap-2"
|
||||
title="눈금자 표시 켜기/끄기"
|
||||
>
|
||||
<RulerIcon className="h-4 w-4" />
|
||||
{showRuler ? "눈금자 ON" : "눈금자 OFF"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-8 w-8 ${snapToGrid && showGrid ? "bg-white shadow-sm" : ""}`}
|
||||
onClick={handleToggleGrid}
|
||||
title="Grid Snap 및 표시 켜기/끄기"
|
||||
>
|
||||
<Grid3x3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 줌 도구 */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg bg-gray-50 px-1 py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setZoom(Math.max(0.1, Math.round((zoom - 0.1) * 10) / 10))}
|
||||
title="축소"
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
className="min-w-[46px] rounded px-1 py-1 text-center text-xs font-medium text-gray-700 hover:bg-gray-200"
|
||||
onClick={fitToScreen}
|
||||
title="화면에 맞추기"
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setZoom(Math.min(3, Math.round((zoom + 0.1) * 10) / 10))}
|
||||
title="확대"
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={fitToScreen}
|
||||
title="화면에 맞추기"
|
||||
>
|
||||
<Maximize className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 편집 도구 */}
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg bg-gray-50 px-1 py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
className="gap-2"
|
||||
title="실행 취소 (Ctrl+Z)"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
className="gap-2"
|
||||
title="다시 실행 (Ctrl+Shift+Z)"
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 정렬 드롭다운 */}
|
||||
{/* 정렬 도구 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canAlign}
|
||||
className="gap-2"
|
||||
title="정렬 (2개 이상 선택 필요)"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="h-9 gap-2" title="정렬 및 배치 도구">
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
정렬
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={alignLeft}>
|
||||
<AlignLeft className="mr-2 h-4 w-4" />
|
||||
왼쪽 정렬
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500">정렬 (2개 이상 선택)</div>
|
||||
<div className="grid grid-cols-2 gap-1 p-1">
|
||||
<DropdownMenuItem onClick={alignLeft} disabled={!canAlign} className="justify-center">
|
||||
<AlignLeft className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignRight}>
|
||||
<AlignRight className="mr-2 h-4 w-4" />
|
||||
오른쪽 정렬
|
||||
<DropdownMenuItem onClick={alignRight} disabled={!canAlign} className="justify-center">
|
||||
<AlignRight className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignTop}>
|
||||
<AlignVerticalJustifyStart className="mr-2 h-4 w-4" />
|
||||
위쪽 정렬
|
||||
<DropdownMenuItem onClick={alignTop} disabled={!canAlign} className="justify-center">
|
||||
<AlignVerticalJustifyStart className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignBottom}>
|
||||
<AlignVerticalJustifyEnd className="mr-2 h-4 w-4" />
|
||||
아래쪽 정렬
|
||||
<DropdownMenuItem onClick={alignBottom} disabled={!canAlign} className="justify-center">
|
||||
<AlignVerticalJustifyEnd className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignCenterHorizontal} disabled={!canAlign} className="justify-center">
|
||||
<AlignCenterHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignCenterVertical} disabled={!canAlign} className="justify-center">
|
||||
<AlignCenterVertical className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={alignCenterHorizontal}>
|
||||
<AlignCenterHorizontal className="mr-2 h-4 w-4" />
|
||||
가로 중앙 정렬
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={alignCenterVertical}>
|
||||
<AlignCenterVertical className="mr-2 h-4 w-4" />
|
||||
세로 중앙 정렬
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 배치 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canDistribute}
|
||||
className="gap-2"
|
||||
title="균등 배치 (3개 이상 선택 필요)"
|
||||
>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500">배치 (3개 이상 선택)</div>
|
||||
<div className="grid grid-cols-2 gap-1 p-1">
|
||||
<DropdownMenuItem onClick={distributeHorizontal} disabled={!canDistribute} className="justify-center">
|
||||
<AlignHorizontalDistributeCenter className="h-4 w-4" />
|
||||
배치
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={distributeHorizontal}>
|
||||
<AlignHorizontalDistributeCenter className="mr-2 h-4 w-4" />
|
||||
가로 균등 배치
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={distributeVertical}>
|
||||
<AlignVerticalDistributeCenter className="mr-2 h-4 w-4" />
|
||||
세로 균등 배치
|
||||
<DropdownMenuItem onClick={distributeVertical} disabled={!canDistribute} className="justify-center">
|
||||
<AlignVerticalDistributeCenter className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* 크기 조정 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500">크기 맞춤 (2개 이상 선택)</div>
|
||||
<div className="grid grid-cols-3 gap-1 p-1">
|
||||
<DropdownMenuItem
|
||||
onClick={makeSameWidth}
|
||||
disabled={!canAlign}
|
||||
className="gap-2"
|
||||
title="크기 조정 (2개 이상 선택 필요)"
|
||||
className="justify-center"
|
||||
title="같은 너비로"
|
||||
>
|
||||
<RectangleHorizontal className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={makeSameHeight}
|
||||
disabled={!canAlign}
|
||||
className="justify-center"
|
||||
title="같은 높이로"
|
||||
>
|
||||
<RectangleVertical className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={makeSameSize}
|
||||
disabled={!canAlign}
|
||||
className="justify-center"
|
||||
title="같은 크기로"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
크기
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={makeSameWidth}>
|
||||
<RectangleHorizontal className="mr-2 h-4 w-4" />
|
||||
같은 너비로
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={makeSameHeight}>
|
||||
<RectangleVertical className="mr-2 h-4 w-4" />
|
||||
같은 높이로
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={makeSameSize}>
|
||||
<Square className="mr-2 h-4 w-4" />
|
||||
같은 크기로
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* 레이어 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasSelection}
|
||||
className="gap-2"
|
||||
title="레이어 순서 (1개 이상 선택 필요)"
|
||||
>
|
||||
<ChevronsUp className="h-4 w-4" />
|
||||
레이어
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={bringToFront}>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500">레이어 및 그룹 (1개 이상 선택)</div>
|
||||
<DropdownMenuItem onClick={bringToFront} disabled={!hasSelection}>
|
||||
<ChevronsUp className="mr-2 h-4 w-4" /> 맨 앞으로
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={bringForward}>
|
||||
<ChevronUp className="mr-2 h-4 w-4" />한 단계 앞으로
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={sendBackward}>
|
||||
<ChevronDown className="mr-2 h-4 w-4" />한 단계 뒤로
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={sendToBack}>
|
||||
<DropdownMenuItem onClick={sendToBack} disabled={!hasSelection}>
|
||||
<ChevronsDown className="mr-2 h-4 w-4" /> 맨 뒤로
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 잠금 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasSelection}
|
||||
className="gap-2"
|
||||
title="컴포넌트 잠금/해제 (1개 이상 선택 필요)"
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
잠금
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={toggleLock}>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
토글 (잠금/해제)
|
||||
<DropdownMenuItem onClick={toggleLock} disabled={!hasSelection}>
|
||||
<Lock className="mr-2 h-4 w-4" /> 잠금/해제
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={lockComponents}>
|
||||
<Lock className="mr-2 h-4 w-4" />
|
||||
잠금 설정
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={unlockComponents}>
|
||||
<Unlock className="mr-2 h-4 w-4" />
|
||||
잠금 해제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* 그룹화 드롭다운 */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasSelection}
|
||||
className="gap-2"
|
||||
title="컴포넌트 그룹화/해제"
|
||||
>
|
||||
<Group className="h-4 w-4" />
|
||||
그룹
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={groupComponents} disabled={!canGroup}>
|
||||
<Group className="mr-2 h-4 w-4" />
|
||||
그룹화 (2개 이상)
|
||||
<Group className="mr-2 h-4 w-4" /> 그룹화
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={ungroupComponents} disabled={!hasSelection}>
|
||||
<Ungroup className="mr-2 h-4 w-4" />
|
||||
그룹 해제
|
||||
<Ungroup className="mr-2 h-4 w-4" /> 그룹 해제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => setShowResetConfirm(true)} className="gap-2">
|
||||
{/* 우측: 액션 버튼들 (패널 너비에 연동) */}
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-end gap-1 pr-2 transition-all duration-200 lg:gap-2 lg:pr-3"
|
||||
style={{ width: `${Math.max(rightToolbarWidth, 220)}px` }}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowResetConfirm(true)} className="h-9 gap-1 px-2 lg:gap-2 lg:px-3">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
초기화
|
||||
<span className="hidden xl:inline">초기화</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowPreview(true)} className="gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowPreview(true)} className="h-9 gap-1 px-2 lg:gap-2 lg:px-3">
|
||||
<Eye className="h-4 w-4" />
|
||||
미리보기
|
||||
<span className="hidden xl:inline">미리보기</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSaveAsTemplate(true)}
|
||||
disabled={!canSaveAsTemplate}
|
||||
className="gap-2"
|
||||
className="hidden h-9 gap-2 xl:flex"
|
||||
title={!canSaveAsTemplate ? "컴포넌트를 추가한 후 템플릿으로 저장할 수 있습니다" : ""}
|
||||
>
|
||||
<BookTemplate className="h-4 w-4" />
|
||||
템플릿으로 저장
|
||||
템플릿 저장
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-2">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
저장
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="h-9 gap-1 px-2 lg:gap-2 lg:px-3">
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="hidden lg:inline">저장</span>
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSaveAndClose} disabled={isSaving} className="gap-2">
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
저장 후 닫기
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveAndClose}
|
||||
disabled={isSaving}
|
||||
className="h-9 gap-1 bg-blue-600 px-2 text-white hover:bg-blue-700 lg:gap-2 lg:px-3"
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="hidden lg:inline">저장 후 닫기</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { JSX } from "react";
|
||||
import { MM_TO_PX } from "@/lib/report/constants";
|
||||
|
||||
interface RulerProps {
|
||||
orientation: "horizontal" | "vertical";
|
||||
length: number; // mm 단위
|
||||
offset?: number; // 스크롤 오프셋 (px)
|
||||
length: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// 고정 스케일 팩터 (화면 해상도와 무관)
|
||||
const MM_TO_PX = 4;
|
||||
|
||||
export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
|
||||
// mm를 px로 변환
|
||||
const mmToPx = (mm: number) => mm * MM_TO_PX;
|
||||
@@ -31,7 +29,7 @@ export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
|
||||
ticks.push(
|
||||
<div
|
||||
key={`major-${mm}`}
|
||||
className="absolute bg-foreground/90"
|
||||
className="absolute bg-gray-700"
|
||||
style={
|
||||
isHorizontal
|
||||
? {
|
||||
@@ -55,7 +53,7 @@ export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
|
||||
ticks.push(
|
||||
<div
|
||||
key={`label-${mm}`}
|
||||
className="absolute text-[9px] text-muted-foreground"
|
||||
className="absolute text-[9px] text-gray-600"
|
||||
style={
|
||||
isHorizontal
|
||||
? {
|
||||
@@ -79,7 +77,7 @@ export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
|
||||
ticks.push(
|
||||
<div
|
||||
key={`medium-${mm}`}
|
||||
className="absolute bg-muted0"
|
||||
className="absolute bg-gray-500"
|
||||
style={
|
||||
isHorizontal
|
||||
? {
|
||||
@@ -103,7 +101,7 @@ export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
|
||||
ticks.push(
|
||||
<div
|
||||
key={`minor-${mm}`}
|
||||
className="absolute bg-muted-foreground"
|
||||
className="absolute bg-gray-400"
|
||||
style={
|
||||
isHorizontal
|
||||
? {
|
||||
@@ -129,7 +127,7 @@ export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative bg-muted select-none"
|
||||
className="relative bg-gray-100 select-none"
|
||||
style={
|
||||
isHorizontal
|
||||
? {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -29,6 +30,22 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const hasInputData = useCallback(() => {
|
||||
return formData.templateNameKor.trim() !== "" ||
|
||||
formData.templateNameEng.trim() !== "" ||
|
||||
formData.description.trim() !== "";
|
||||
}, [formData]);
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
hasChanges: () => !isSaving && hasInputData(),
|
||||
onClose: () => {
|
||||
setFormData({ templateNameKor: "", templateNameEng: "", description: "" });
|
||||
onClose();
|
||||
},
|
||||
title: "입력된 내용이 있습니다",
|
||||
description: "입력된 내용이 저장되지 않습니다. 정말 닫으시겠습니까?",
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.templateNameKor.trim()) {
|
||||
alert("템플릿명을 입력해주세요.");
|
||||
@@ -43,12 +60,7 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
|
||||
description: formData.description || undefined,
|
||||
});
|
||||
|
||||
// 초기화
|
||||
setFormData({
|
||||
templateNameKor: "",
|
||||
templateNameEng: "",
|
||||
description: "",
|
||||
});
|
||||
setFormData({ templateNameKor: "", templateNameEng: "", description: "" });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("템플릿 저장 실패:", error);
|
||||
@@ -57,19 +69,9 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isSaving) {
|
||||
setFormData({
|
||||
templateNameKor: "",
|
||||
templateNameEng: "",
|
||||
description: "",
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={guard.handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>템플릿으로 저장</DialogTitle>
|
||||
@@ -81,7 +83,7 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="templateNameKor">
|
||||
템플릿명 (한국어) <span className="text-destructive">*</span>
|
||||
템플릿명 (한국어) <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="templateNameKor"
|
||||
@@ -132,7 +134,7 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isSaving}>
|
||||
<Button variant="outline" onClick={guard.tryClose} disabled={isSaving}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
@@ -148,5 +150,8 @@ export function SaveAsTemplateModal({ isOpen, onClose, onSave }: SaveAsTemplateM
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<UnsavedChangesDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ export function SignatureGenerator({ onSignatureSelect }: SignatureGeneratorProp
|
||||
{generatedSignatures.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">레이블 위치</Label>
|
||||
<p className="text-xs text-muted-foreground">더블클릭하여 서명을 선택하세요</p>
|
||||
<p className="text-xs text-gray-500">더블클릭하여 서명을 선택하세요</p>
|
||||
<ScrollArea className="h-[300px] rounded-md border bg-white">
|
||||
<div className="space-y-2 p-2">
|
||||
{generatedSignatures.map((signature, index) => (
|
||||
@@ -255,7 +255,7 @@ export function SignatureGenerator({ onSignatureSelect }: SignatureGeneratorProp
|
||||
alt={`서명 ${index + 1}`}
|
||||
className="h-auto max-h-[45px] w-auto max-w-[280px] object-contain"
|
||||
/>
|
||||
<p className="ml-2 text-xs text-muted-foreground/70 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<p className="ml-2 text-xs text-gray-400 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{fonts[index].name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -107,7 +107,7 @@ export function SignaturePad({ onSignatureChange, initialSignature }: SignatureP
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Card className="overflow-hidden border-2 border-dashed border-input bg-white p-2">
|
||||
<Card className="overflow-hidden border-2 border-dashed border-gray-300 bg-white p-2">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={300}
|
||||
@@ -121,7 +121,7 @@ export function SignaturePad({ onSignatureChange, initialSignature }: SignatureP
|
||||
/>
|
||||
</Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-gray-500">
|
||||
<Pen className="mr-1 inline h-3 w-3" />
|
||||
마우스로 서명해주세요
|
||||
</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, Loader2, RefreshCw } from "lucide-react";
|
||||
import { Trash2, Loader2 } from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -92,20 +92,13 @@ export function TemplatePalette() {
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 사용자 정의 템플릿 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button variant="ghost" size="sm" onClick={fetchTemplates} disabled={isLoading} className="h-6 w-6 p-0">
|
||||
<RefreshCw className={`h-3 w-3 ${isLoading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground/70" />
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : customTemplates.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground/70">저장된 템플릿이 없습니다</p>
|
||||
<p className="py-4 text-center text-xs text-gray-400">저장된 템플릿이 없습니다</p>
|
||||
) : (
|
||||
customTemplates.map((template) => (
|
||||
<div key={template.template_id} className="group relative">
|
||||
@@ -131,7 +124,7 @@ export function TemplatePalette() {
|
||||
{deletingId === template.template_id ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
<Trash2 className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import { WatermarkConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
watermark: WatermarkConfig;
|
||||
/** 캔버스/페이지 너비 (px) */
|
||||
width: number;
|
||||
/** 캔버스/페이지 높이 (px) */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 워터마크 레이어 공용 컴포넌트
|
||||
*
|
||||
* ReportDesignerCanvas 와 ReportPreviewModal 양쪽에서 사용.
|
||||
* imageUrl 이 "data:" 로 시작하면 그대로 사용하고,
|
||||
* 서버 경로인 경우 getFullImageUrl 로 변환한다.
|
||||
*/
|
||||
export function WatermarkLayer({ watermark, width, height }: Props) {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
zIndex: 1,
|
||||
};
|
||||
|
||||
const rotation = watermark.rotation ?? -45;
|
||||
|
||||
const resolveImageSrc = (url: string): string => (url.startsWith("data:") ? url : getFullImageUrl(url));
|
||||
|
||||
const renderContent = (tileFontSize?: number) => {
|
||||
if (watermark.type === "text") {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${tileFontSize ?? watermark.fontSize ?? 48}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (watermark.imageUrl) {
|
||||
return null; // 이미지는 각 스타일 블록에서 직접 렌더링
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 대각선 스타일
|
||||
if (watermark.style === "diagonal") {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text"
|
||||
? renderContent()
|
||||
: watermark.imageUrl && (
|
||||
<img
|
||||
src={resolveImageSrc(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
maxWidth: "50%",
|
||||
maxHeight: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 중앙 스타일
|
||||
if (watermark.style === "center") {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text"
|
||||
? renderContent()
|
||||
: watermark.imageUrl && (
|
||||
<img
|
||||
src={resolveImageSrc(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
maxWidth: "50%",
|
||||
maxHeight: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 타일 스타일
|
||||
if (watermark.style === "tile") {
|
||||
const tileRotation = watermark.rotation ?? -30;
|
||||
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
||||
const cols = Math.ceil(width / tileSize) + 2;
|
||||
const rows = Math.ceil(height / tileSize) + 2;
|
||||
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-50%",
|
||||
left: "-50%",
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignContent: "flex-start",
|
||||
transform: `rotate(${tileRotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: rows * cols }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
width: `${tileSize}px`,
|
||||
height: `${tileSize}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text"
|
||||
? renderContent(watermark.fontSize || 24)
|
||||
: watermark.imageUrl && (
|
||||
<img
|
||||
src={resolveImageSrc(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
width: `${tileSize * 0.6}px`,
|
||||
height: `${tileSize * 0.6}px`,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useDrag } from "react-dnd";
|
||||
import {
|
||||
Type, CreditCard, Minus, Tag,
|
||||
ImageIcon, Hash, Calendar, Link2, Circle, Space, FileText,
|
||||
} from "lucide-react";
|
||||
import type { CardElementType } from "@/types/report";
|
||||
|
||||
interface CardElementItem {
|
||||
type: CardElementType;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const CARD_ELEMENTS: CardElementItem[] = [
|
||||
{ type: "header", label: "헤더", icon: <Type className="h-4 w-4" /> },
|
||||
{ type: "dataCell", label: "데이터 셀", icon: <CreditCard className="h-4 w-4" /> },
|
||||
{ type: "divider", label: "구분선", icon: <Minus className="h-4 w-4" /> },
|
||||
{ type: "badge", label: "뱃지", icon: <Tag className="h-4 w-4" /> },
|
||||
{ type: "image", label: "이미지", icon: <ImageIcon className="h-4 w-4" /> },
|
||||
{ type: "number", label: "숫자/금액", icon: <Hash className="h-4 w-4" /> },
|
||||
{ type: "date", label: "날짜", icon: <Calendar className="h-4 w-4" /> },
|
||||
{ type: "link", label: "링크", icon: <Link2 className="h-4 w-4" /> },
|
||||
{ type: "status", label: "상태", icon: <Circle className="h-4 w-4" /> },
|
||||
{ type: "spacer", label: "빈 공간", icon: <Space className="h-4 w-4" /> },
|
||||
{ type: "staticText", label: "고정 텍스트", icon: <FileText className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
export const CARD_ELEMENT_DND_TYPE = "card-element";
|
||||
|
||||
interface DraggableElementProps {
|
||||
type: CardElementType;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
function DraggableElement({ type, label, icon }: DraggableElementProps) {
|
||||
const [{ isDragging }, drag] = useDrag(() => ({
|
||||
type: CARD_ELEMENT_DND_TYPE,
|
||||
item: { elementType: type },
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drag as any}
|
||||
className={`flex cursor-move flex-col items-center justify-center gap-1 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 transition-colors hover:border-blue-400 hover:bg-blue-50 ${
|
||||
isDragging ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="text-gray-600">{icon}</div>
|
||||
<span className="text-xs font-medium text-gray-700">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardElementPalette() {
|
||||
return (
|
||||
<div className="bg-white border border-border rounded-xl p-4">
|
||||
<div className="text-sm font-bold text-gray-800 mb-3">
|
||||
요소 팔레트
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">드래그하여 추가</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{CARD_ELEMENTS.map((item) => (
|
||||
<DraggableElement key={item.type} {...item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Database,
|
||||
Palette,
|
||||
X,
|
||||
Type,
|
||||
CreditCard,
|
||||
Minus,
|
||||
Tag,
|
||||
} from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import type {
|
||||
CardLayoutConfig,
|
||||
CardLayoutRow,
|
||||
CardElement,
|
||||
CardDataCellElement,
|
||||
CardBadgeElement,
|
||||
} from "@/types/report";
|
||||
import { CardElementPalette } from "./CardElementPalette";
|
||||
import { CardCanvasEditor } from "./CardCanvasEditor";
|
||||
|
||||
interface CardLayoutModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialConfig?: CardLayoutConfig;
|
||||
onSave: (config: CardLayoutConfig) => void;
|
||||
}
|
||||
|
||||
type TabType = "layout" | "binding" | "style";
|
||||
|
||||
interface TableInfo {
|
||||
table_name: string;
|
||||
table_type: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}
|
||||
|
||||
const generateId = () =>
|
||||
`row_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
const DEFAULT_CONFIG: CardLayoutConfig = {
|
||||
tableName: "",
|
||||
primaryKey: "",
|
||||
rows: [
|
||||
{
|
||||
id: generateId(),
|
||||
gridColumns: 4,
|
||||
elements: [],
|
||||
},
|
||||
],
|
||||
padding: "12px",
|
||||
gap: "8px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "#e5e7eb",
|
||||
backgroundColor: "#ffffff",
|
||||
headerTitleFontSize: 14,
|
||||
headerTitleColor: "#1e40af",
|
||||
labelFontSize: 13,
|
||||
labelColor: "#374151",
|
||||
valueFontSize: 13,
|
||||
valueColor: "#000000",
|
||||
dividerThickness: 1,
|
||||
dividerColor: "#e5e7eb",
|
||||
};
|
||||
|
||||
const getElementIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "header":
|
||||
return <Type className="w-3 h-3" />;
|
||||
case "dataCell":
|
||||
return <CreditCard className="w-3 h-3" />;
|
||||
case "divider":
|
||||
return <Minus className="w-3 h-3" />;
|
||||
case "badge":
|
||||
return <Tag className="w-3 h-3" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function CardLayoutModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialConfig,
|
||||
onSave,
|
||||
}: CardLayoutModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>("layout");
|
||||
const [config, setConfig] = useState<CardLayoutConfig>(
|
||||
initialConfig || DEFAULT_CONFIG,
|
||||
);
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const initialSnapshotRef = useRef<string>("");
|
||||
|
||||
const hasChanges = useCallback(() => {
|
||||
return JSON.stringify(config) !== initialSnapshotRef.current;
|
||||
}, [config]);
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
hasChanges,
|
||||
onClose: () => onOpenChange(false),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const initConfig = initialConfig || DEFAULT_CONFIG;
|
||||
setConfig(initConfig);
|
||||
initialSnapshotRef.current = JSON.stringify(initConfig);
|
||||
setActiveTab("layout");
|
||||
fetchTables();
|
||||
}
|
||||
}, [open, initialConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.tableName) {
|
||||
fetchColumns(config.tableName);
|
||||
} else {
|
||||
setColumns([]);
|
||||
}
|
||||
}, [config.tableName]);
|
||||
|
||||
const fetchTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await reportApi.getSchemaTableList();
|
||||
if (response.success) {
|
||||
setTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchColumns = async (tableName: string) => {
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const response = await reportApi.getSchemaTableColumns(tableName);
|
||||
if (response.success) {
|
||||
setColumns(response.data);
|
||||
const pkCandidate = response.data.find(
|
||||
(col) =>
|
||||
col.column_name.endsWith("_id") ||
|
||||
col.column_name === "id" ||
|
||||
col.column_name.endsWith("_pk"),
|
||||
);
|
||||
if (pkCandidate && !config.primaryKey) {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
primaryKey: pkCandidate.column_name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
const usedColumns = useMemo(() => {
|
||||
const used = new Set<string>();
|
||||
config.rows.forEach((row) => {
|
||||
row.elements.forEach((el) => {
|
||||
if (el.type === "dataCell" && (el as CardDataCellElement).columnName) {
|
||||
used.add((el as CardDataCellElement).columnName!);
|
||||
}
|
||||
if (el.type === "badge" && (el as CardBadgeElement).columnName) {
|
||||
used.add((el as CardBadgeElement).columnName!);
|
||||
}
|
||||
});
|
||||
});
|
||||
return used;
|
||||
}, [config.rows]);
|
||||
|
||||
const handleTableChange = (tableName: string) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
tableName,
|
||||
primaryKey: "",
|
||||
rows: prev.rows.map((row) => ({
|
||||
...row,
|
||||
elements: row.elements.map((el) => {
|
||||
if (el.type === "dataCell") {
|
||||
return { ...el, columnName: undefined } as CardDataCellElement;
|
||||
}
|
||||
if (el.type === "badge") {
|
||||
return { ...el, columnName: undefined } as CardBadgeElement;
|
||||
}
|
||||
return el;
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRowsChange = (rows: CardLayoutRow[]) => {
|
||||
setConfig((prev) => ({ ...prev, rows }));
|
||||
};
|
||||
|
||||
const handleColumnMapping = (
|
||||
rowIndex: number,
|
||||
elementIndex: number,
|
||||
columnName: string,
|
||||
) => {
|
||||
setConfig((prev) => {
|
||||
const newRows = [...prev.rows];
|
||||
const newElements = [...newRows[rowIndex].elements];
|
||||
const element = newElements[elementIndex];
|
||||
if (element.type === "dataCell") {
|
||||
newElements[elementIndex] = {
|
||||
...element,
|
||||
columnName,
|
||||
} as CardDataCellElement;
|
||||
} else if (element.type === "badge") {
|
||||
newElements[elementIndex] = {
|
||||
...element,
|
||||
columnName,
|
||||
} as CardBadgeElement;
|
||||
}
|
||||
newRows[rowIndex] = { ...newRows[rowIndex], elements: newElements };
|
||||
return { ...prev, rows: newRows };
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(config);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const renderLayoutTab = () => (
|
||||
<div className="space-y-4">
|
||||
<CardElementPalette />
|
||||
<CardCanvasEditor rows={config.rows} onRowsChange={handleRowsChange} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBindingTab = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-teal-50 border border-teal-200 rounded-xl p-4 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">
|
||||
테이블 선택
|
||||
</Label>
|
||||
<Select
|
||||
value={config.tableName || ""}
|
||||
onValueChange={handleTableChange}
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue
|
||||
placeholder={loadingTables ? "로딩 중..." : "테이블을 선택하세요"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
{table.table_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
기본 키
|
||||
</Label>
|
||||
<Select
|
||||
value={config.primaryKey || ""}
|
||||
onValueChange={(pk) => setConfig((prev) => ({ ...prev, primaryKey: pk }))}
|
||||
disabled={!config.tableName || loadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm bg-muted">
|
||||
<SelectValue placeholder="기본 키 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div
|
||||
className="p-3"
|
||||
style={{
|
||||
backgroundColor: config.backgroundColor,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: config.gap,
|
||||
}}
|
||||
>
|
||||
{config.rows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${row.gridColumns}, 1fr)`,
|
||||
gap: config.gap,
|
||||
}}
|
||||
>
|
||||
{row.elements.map((element, elementIndex) => {
|
||||
const needsBinding =
|
||||
element.type === "dataCell" || element.type === "badge";
|
||||
const currentColumn =
|
||||
element.type === "dataCell"
|
||||
? (element as CardDataCellElement).columnName
|
||||
: element.type === "badge"
|
||||
? (element as CardBadgeElement).columnName
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className="border border-gray-100 rounded px-2 py-1.5 hover:bg-gray-50"
|
||||
style={{ gridColumn: `span ${element.colspan || 1}` }}
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span className="text-gray-400">
|
||||
{getElementIcon(element.type)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{element.type === "header" && (element as any).title}
|
||||
{element.type === "dataCell" &&
|
||||
(element as CardDataCellElement).label}
|
||||
{element.type === "divider" && "구분선"}
|
||||
{element.type === "badge" &&
|
||||
((element as CardBadgeElement).label || "뱃지")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{needsBinding && (
|
||||
<Select
|
||||
value={currentColumn || ""}
|
||||
onValueChange={(col) =>
|
||||
handleColumnMapping(rowIndex, elementIndex, col)
|
||||
}
|
||||
disabled={!config.tableName}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={`h-7 text-xs ${
|
||||
currentColumn
|
||||
? "bg-blue-50 text-blue-700 border-blue-200"
|
||||
: "border-dashed"
|
||||
}`}
|
||||
>
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => {
|
||||
const isUsed =
|
||||
usedColumns.has(col.column_name) &&
|
||||
currentColumn !== col.column_name;
|
||||
return (
|
||||
<SelectItem
|
||||
key={col.column_name}
|
||||
value={col.column_name}
|
||||
disabled={isUsed}
|
||||
className={isUsed ? "opacity-50" : ""}
|
||||
>
|
||||
{col.column_name}
|
||||
{isUsed && " (사용 중)"}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!config.tableName && (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">
|
||||
테이블을 먼저 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.tableName && config.rows.every((r) => r.elements.length === 0) && (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">
|
||||
레이아웃 탭에서 요소를 먼저 추가해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderStyleTab = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white border border-border rounded-xl p-4 space-y-3">
|
||||
<div className="text-xs font-medium text-foreground mb-2">카드 스타일</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">패딩</Label>
|
||||
<Select value={config.padding || "12px"} onValueChange={(v) => setConfig((prev) => ({ ...prev, padding: v }))}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="8px">8px</SelectItem>
|
||||
<SelectItem value="12px">12px</SelectItem>
|
||||
<SelectItem value="16px">16px</SelectItem>
|
||||
<SelectItem value="20px">20px</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">행 간격</Label>
|
||||
<Select value={config.gap || "8px"} onValueChange={(v) => setConfig((prev) => ({ ...prev, gap: v }))}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="4px">4px</SelectItem>
|
||||
<SelectItem value="8px">8px</SelectItem>
|
||||
<SelectItem value="12px">12px</SelectItem>
|
||||
<SelectItem value="16px">16px</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">테두리</Label>
|
||||
<Select value={config.borderStyle || "solid"} onValueChange={(v) => setConfig((prev) => ({ ...prev, borderStyle: v }))}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">실선</SelectItem>
|
||||
<SelectItem value="dashed">점선</SelectItem>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">테두리 색상</Label>
|
||||
<Input type="color" value={config.borderColor || "#e5e7eb"} onChange={(e) => setConfig((prev) => ({ ...prev, borderColor: e.target.value }))} className="h-9 w-full p-1" />
|
||||
</div>
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label className="text-xs font-medium text-foreground">배경색</Label>
|
||||
<Input type="color" value={config.backgroundColor || "#ffffff"} onChange={(e) => setConfig((prev) => ({ ...prev, backgroundColor: e.target.value }))} className="h-9 w-full p-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white border border-border rounded-xl p-4 space-y-3">
|
||||
<div className="text-xs font-medium text-foreground mb-2">요소별 스타일</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">헤더 폰트 크기</Label>
|
||||
<Input type="number" min={10} max={24} value={config.headerTitleFontSize || 14} onChange={(e) => setConfig((prev) => ({ ...prev, headerTitleFontSize: parseInt(e.target.value) || 14 }))} className="h-9 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">헤더 색상</Label>
|
||||
<Input type="color" value={config.headerTitleColor || "#1e40af"} onChange={(e) => setConfig((prev) => ({ ...prev, headerTitleColor: e.target.value }))} className="h-9 w-full p-1" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">라벨 폰트 크기</Label>
|
||||
<Input type="number" min={10} max={20} value={config.labelFontSize || 13} onChange={(e) => setConfig((prev) => ({ ...prev, labelFontSize: parseInt(e.target.value) || 13 }))} className="h-9 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">라벨 색상</Label>
|
||||
<Input type="color" value={config.labelColor || "#374151"} onChange={(e) => setConfig((prev) => ({ ...prev, labelColor: e.target.value }))} className="h-9 w-full p-1" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">값 폰트 크기</Label>
|
||||
<Input type="number" min={10} max={20} value={config.valueFontSize || 13} onChange={(e) => setConfig((prev) => ({ ...prev, valueFontSize: parseInt(e.target.value) || 13 }))} className="h-9 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">값 색상</Label>
|
||||
<Input type="color" value={config.valueColor || "#000000"} onChange={(e) => setConfig((prev) => ({ ...prev, valueColor: e.target.value }))} className="h-9 w-full p-1" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">구분선 두께</Label>
|
||||
<Input type="number" min={1} max={5} value={config.dividerThickness || 1} onChange={(e) => setConfig((prev) => ({ ...prev, dividerThickness: parseInt(e.target.value) || 1 }))} className="h-9 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">구분선 색상</Label>
|
||||
<Input type="color" value={config.dividerColor || "#e5e7eb"} onChange={(e) => setConfig((prev) => ({ ...prev, dividerColor: e.target.value }))} className="h-9 w-full p-1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={guard.handleOpenChange}>
|
||||
<DialogContent className="max-w-4xl h-[92vh] overflow-hidden flex flex-col p-0 [&>button]:hidden">
|
||||
<DialogTitle className="sr-only">카드 레이아웃 설정</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
카드 컴포넌트의 레이아웃, 데이터 바인딩, 스타일을 설정합니다
|
||||
</DialogDescription>
|
||||
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutGrid className="w-4 h-4 text-blue-600" />
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
카드 레이아웃 설정
|
||||
</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={guard.tryClose}
|
||||
className="w-8 h-8"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab */}
|
||||
<div className="mx-6 mt-3">
|
||||
<div className="h-9 bg-muted/30 rounded-lg p-0.5 inline-flex">
|
||||
<button
|
||||
onClick={() => setActiveTab("layout")}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
|
||||
activeTab === "layout"
|
||||
? "bg-blue-50 text-blue-700 shadow-sm"
|
||||
: "bg-transparent text-foreground hover:text-foreground/80"
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
레이아웃
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("binding")}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
|
||||
activeTab === "binding"
|
||||
? "bg-blue-50 text-blue-700 shadow-sm"
|
||||
: "bg-transparent text-foreground hover:text-foreground/80"
|
||||
}`}
|
||||
>
|
||||
<Database className="w-3.5 h-3.5" />
|
||||
데이터 바인딩
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("style")}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
|
||||
activeTab === "style"
|
||||
? "bg-blue-50 text-blue-700 shadow-sm"
|
||||
: "bg-transparent text-foreground hover:text-foreground/80"
|
||||
}`}
|
||||
>
|
||||
<Palette className="w-3.5 h-3.5" />
|
||||
스타일
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{activeTab === "layout" && renderLayoutTab()}
|
||||
{activeTab === "binding" && renderBindingTab()}
|
||||
{activeTab === "style" && renderStyleTab()}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-border flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={guard.tryClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleSave}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<UnsavedChangesDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CardLayoutTabs.tsx — 카드 컴포넌트 설정 탭
|
||||
*
|
||||
* [역할]
|
||||
* - 카드 컴포넌트의 레이아웃 구성 / 데이터 연결 / 표시 조건을 3탭 구조로 제공
|
||||
* - ComponentSettingsModal 내에 직접 임베드되어 사용
|
||||
*
|
||||
* [사용처]
|
||||
* - CardProperties.tsx (section="data"일 때)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Database,
|
||||
CreditCard as CreditCardIcon,
|
||||
Eye,
|
||||
Type,
|
||||
CreditCard,
|
||||
Minus,
|
||||
Tag,
|
||||
ImageIcon,
|
||||
Hash,
|
||||
Calendar,
|
||||
Link2,
|
||||
Circle,
|
||||
Space,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import type {
|
||||
CardLayoutConfig,
|
||||
CardLayoutRow,
|
||||
CardDataCellElement,
|
||||
CardBadgeElement,
|
||||
CardNumberElement,
|
||||
CardDateElement,
|
||||
CardLinkElement,
|
||||
CardStatusElement,
|
||||
CardImageElement,
|
||||
ComponentConfig,
|
||||
} from "@/types/report";
|
||||
import { CardElementPalette } from "./CardElementPalette";
|
||||
import { CardCanvasEditor } from "./CardCanvasEditor";
|
||||
import { ConditionalProperties } from "../properties/ConditionalProperties";
|
||||
import type { CardColumnLabel } from "../properties/ConditionalProperties";
|
||||
|
||||
interface CardLayoutTabsProps {
|
||||
config: CardLayoutConfig;
|
||||
onConfigChange: (config: CardLayoutConfig) => void;
|
||||
component?: ComponentConfig;
|
||||
onComponentChange?: (updates: Partial<ComponentConfig>) => void;
|
||||
}
|
||||
|
||||
type TabType = "layout" | "binding" | "condition";
|
||||
|
||||
interface TableInfo {
|
||||
table_name: string;
|
||||
table_type: string;
|
||||
}
|
||||
|
||||
interface ColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}
|
||||
|
||||
const getElementIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "header":
|
||||
return <Type className="w-3 h-3" />;
|
||||
case "dataCell":
|
||||
return <CreditCard className="w-3 h-3" />;
|
||||
case "divider":
|
||||
return <Minus className="w-3 h-3" />;
|
||||
case "badge":
|
||||
return <Tag className="w-3 h-3" />;
|
||||
case "image":
|
||||
return <ImageIcon className="w-3 h-3" />;
|
||||
case "number":
|
||||
return <Hash className="w-3 h-3" />;
|
||||
case "date":
|
||||
return <Calendar className="w-3 h-3" />;
|
||||
case "link":
|
||||
return <Link2 className="w-3 h-3" />;
|
||||
case "status":
|
||||
return <Circle className="w-3 h-3" />;
|
||||
case "spacer":
|
||||
return <Space className="w-3 h-3" />;
|
||||
case "staticText":
|
||||
return <FileText className="w-3 h-3" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function CardLayoutTabs({
|
||||
config,
|
||||
onConfigChange,
|
||||
component,
|
||||
onComponentChange,
|
||||
}: CardLayoutTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>("layout");
|
||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTables();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.tableName) {
|
||||
fetchColumns(config.tableName);
|
||||
} else {
|
||||
setColumns([]);
|
||||
}
|
||||
}, [config.tableName]);
|
||||
|
||||
const fetchTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const response = await reportApi.getSchemaTableList();
|
||||
if (response.success) {
|
||||
setTables(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchColumns = async (tableName: string) => {
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const response = await reportApi.getSchemaTableColumns(tableName);
|
||||
if (response.success) {
|
||||
setColumns(response.data);
|
||||
const pkCandidate = response.data.find(
|
||||
(col) =>
|
||||
col.column_name.endsWith("_id") ||
|
||||
col.column_name === "id" ||
|
||||
col.column_name.endsWith("_pk"),
|
||||
);
|
||||
if (pkCandidate && !config.primaryKey) {
|
||||
onConfigChange({
|
||||
...config,
|
||||
primaryKey: pkCandidate.column_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
const usedColumns = useMemo(() => {
|
||||
const used = new Set<string>();
|
||||
config.rows.forEach((row) => {
|
||||
row.elements.forEach((el) => {
|
||||
const colName =
|
||||
(el as CardDataCellElement | CardBadgeElement | CardNumberElement | CardDateElement | CardLinkElement | CardStatusElement | CardImageElement)
|
||||
.columnName;
|
||||
if (colName) used.add(colName);
|
||||
});
|
||||
});
|
||||
return used;
|
||||
}, [config.rows]);
|
||||
|
||||
const handleTableChange = useCallback(
|
||||
(tableName: string) => {
|
||||
onConfigChange({
|
||||
...config,
|
||||
tableName,
|
||||
primaryKey: "",
|
||||
rows: config.rows.map((row) => ({
|
||||
...row,
|
||||
elements: row.elements.map((el) => {
|
||||
if (el.type === "dataCell") {
|
||||
return { ...el, columnName: undefined } as CardDataCellElement;
|
||||
}
|
||||
if (el.type === "badge") {
|
||||
return { ...el, columnName: undefined } as CardBadgeElement;
|
||||
}
|
||||
return el;
|
||||
}),
|
||||
})),
|
||||
});
|
||||
},
|
||||
[config, onConfigChange],
|
||||
);
|
||||
|
||||
const handleRowsChange = useCallback(
|
||||
(rows: CardLayoutRow[]) => {
|
||||
onConfigChange({ ...config, rows });
|
||||
},
|
||||
[config, onConfigChange],
|
||||
);
|
||||
|
||||
const handleColumnMapping = useCallback(
|
||||
(rowIndex: number, elementIndex: number, columnName: string) => {
|
||||
const newRows = [...config.rows];
|
||||
const newElements = [...newRows[rowIndex].elements];
|
||||
newElements[elementIndex] = { ...newElements[elementIndex], columnName } as typeof newElements[number];
|
||||
newRows[rowIndex] = { ...newRows[rowIndex], elements: newElements };
|
||||
onConfigChange({ ...config, rows: newRows });
|
||||
},
|
||||
[config, onConfigChange],
|
||||
);
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<CardLayoutConfig>) => {
|
||||
onConfigChange({ ...config, ...updates });
|
||||
},
|
||||
[config, onConfigChange],
|
||||
);
|
||||
|
||||
const renderLayoutTab = () => (
|
||||
<div className="space-y-4">
|
||||
<div className="sticky top-0 z-10 bg-white pb-2">
|
||||
<CardElementPalette />
|
||||
</div>
|
||||
<CardCanvasEditor rows={config.rows} onRowsChange={handleRowsChange} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBindingTab = () => (
|
||||
<div className="space-y-4">
|
||||
{/* 데이터 소스 섹션 */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-foreground">
|
||||
테이블 선택
|
||||
</Label>
|
||||
<Select
|
||||
value={config.tableName || ""}
|
||||
onValueChange={handleTableChange}
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue
|
||||
placeholder={loadingTables ? "로딩 중..." : "테이블을 선택하세요"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
{table.table_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
기본 키
|
||||
</Label>
|
||||
<Select
|
||||
value={config.primaryKey || ""}
|
||||
onValueChange={(pk) => updateConfig({ primaryKey: pk })}
|
||||
disabled={!config.tableName || loadingColumns}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm bg-muted">
|
||||
<SelectValue placeholder="기본 키 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카드 WYSIWYG 미리보기 + 인라인 드롭다운 */}
|
||||
<div className="bg-white border border-border rounded-xl overflow-hidden shadow-sm">
|
||||
<div
|
||||
className="p-3"
|
||||
style={{
|
||||
backgroundColor: config.backgroundColor,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: config.gap,
|
||||
}}
|
||||
>
|
||||
{config.rows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${row.gridColumns}, 1fr)`,
|
||||
gap: config.gap,
|
||||
marginBottom: row.marginBottom || undefined,
|
||||
}}
|
||||
>
|
||||
{row.elements.map((element, elementIndex) => {
|
||||
const needsBinding = ["dataCell", "badge", "number", "date", "link", "status", "image"]
|
||||
.includes(element.type);
|
||||
const currentColumn =
|
||||
(element as CardDataCellElement | CardBadgeElement | CardNumberElement | CardDateElement | CardLinkElement | CardStatusElement | CardImageElement)
|
||||
.columnName;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className="border border-gray-100 rounded px-2 py-1.5 hover:bg-gray-50"
|
||||
style={{ gridColumn: `span ${element.colspan || 1}` }}
|
||||
>
|
||||
{/* 요소 타입 표시 */}
|
||||
<div className="flex items-center gap-1 mb-1">
|
||||
<span className="text-gray-400">
|
||||
{getElementIcon(element.type)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{element.type === "header" && (element as any).title}
|
||||
{element.type === "dataCell" && (element as CardDataCellElement).label}
|
||||
{element.type === "divider" && "구분선"}
|
||||
{element.type === "badge" && ((element as CardBadgeElement).label || "뱃지")}
|
||||
{element.type === "image" && "이미지"}
|
||||
{element.type === "number" && ((element as CardNumberElement).label || "숫자/금액")}
|
||||
{element.type === "date" && ((element as CardDateElement).label || "날짜")}
|
||||
{element.type === "link" && ((element as CardLinkElement).label || "링크")}
|
||||
{element.type === "status" && "상태"}
|
||||
{element.type === "spacer" && "빈 공간"}
|
||||
{element.type === "staticText" && "고정 텍스트"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 매핑 드롭다운 */}
|
||||
{needsBinding && (
|
||||
<Select
|
||||
value={currentColumn || ""}
|
||||
onValueChange={(col) =>
|
||||
handleColumnMapping(rowIndex, elementIndex, col)
|
||||
}
|
||||
disabled={!config.tableName}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={`h-7 text-xs ${
|
||||
currentColumn
|
||||
? "bg-blue-50 text-blue-700 border-blue-200"
|
||||
: "border-dashed"
|
||||
}`}
|
||||
>
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => {
|
||||
const isUsed =
|
||||
usedColumns.has(col.column_name) &&
|
||||
currentColumn !== col.column_name;
|
||||
return (
|
||||
<SelectItem
|
||||
key={col.column_name}
|
||||
value={col.column_name}
|
||||
disabled={isUsed}
|
||||
className={isUsed ? "opacity-50" : ""}
|
||||
>
|
||||
{col.column_name}
|
||||
{isUsed && " (사용 중)"}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!config.tableName && (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">
|
||||
테이블을 먼저 선택해주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.tableName && config.rows.every((r) => r.elements.length === 0) && (
|
||||
<div className="text-center text-sm text-muted-foreground py-4">
|
||||
레이아웃 탭에서 요소를 먼저 추가해주세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const cardColumnLabels = useMemo<CardColumnLabel[]>(() => {
|
||||
const labels: CardColumnLabel[] = [];
|
||||
config.rows.forEach((row) => {
|
||||
row.elements.forEach((el) => {
|
||||
const colName = (el as any).columnName as string | undefined;
|
||||
if (!colName) return;
|
||||
|
||||
let label = "";
|
||||
if (el.type === "dataCell") label = (el as any).label || "";
|
||||
else if (el.type === "badge") label = (el as any).label || "뱃지";
|
||||
else if (el.type === "number") label = (el as any).label || "숫자/금액";
|
||||
else if (el.type === "date") label = (el as any).label || "날짜";
|
||||
else if (el.type === "link") label = (el as any).label || "링크";
|
||||
else if (el.type === "status") label = "상태";
|
||||
else if (el.type === "image") label = "이미지";
|
||||
|
||||
if (label) labels.push({ columnName: colName, label });
|
||||
});
|
||||
});
|
||||
return labels;
|
||||
}, [config.rows]);
|
||||
|
||||
const renderConditionTab = () => {
|
||||
if (!component) {
|
||||
return (
|
||||
<div className="text-center text-sm text-muted-foreground py-8">
|
||||
표시 조건을 설정하려면 컴포넌트 정보가 필요합니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ConditionalProperties
|
||||
component={component}
|
||||
onConfigChange={onComponentChange}
|
||||
cardColumns={columns}
|
||||
cardTableName={config.tableName}
|
||||
cardColumnLabels={cardColumnLabels}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const tabs: { key: TabType; icon: React.ReactNode; label: string }[] = [
|
||||
{ key: "layout", icon: <LayoutGrid className="w-3.5 h-3.5" />, label: "레이아웃 구성" },
|
||||
{ key: "binding", icon: <Database className="w-3.5 h-3.5" />, label: "데이터 연결" },
|
||||
{ key: "condition", icon: <Eye className="w-3.5 h-3.5" />, label: "표시 조건" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
{/* 헤더 + 탭 */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<CreditCardIcon className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span className="text-xs font-medium text-foreground">카드 기능 설정</span>
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-1 rounded-lg bg-gray-100 p-1">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
activeTab === tab.key
|
||||
? "bg-white text-blue-700 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div>
|
||||
{activeTab === "layout" && renderLayoutTab()}
|
||||
{activeTab === "binding" && renderBindingTab()}
|
||||
{activeTab === "condition" && renderConditionTab()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { Eye } from "lucide-react";
|
||||
import { CardRenderer } from "../renderers/CardRenderer";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
import type { QueryResult } from "../renderers/types";
|
||||
|
||||
interface CardPreviewPanelProps {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
export function CardPreviewPanel({ component }: CardPreviewPanelProps) {
|
||||
const dummyGetQueryResult = useCallback(
|
||||
(): QueryResult | null => null,
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 p-4 shrink-0">
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2 mb-1 text-sm font-semibold text-gray-700">
|
||||
<Eye className="w-4 h-4 text-blue-600" />
|
||||
미리보기 (저장 전 상태)
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
실제 데이터는 저장 후 확인 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-h-[600px] w-full items-center justify-center rounded-xl border border-gray-200 bg-gray-100 p-8">
|
||||
<div
|
||||
className="w-full overflow-hidden rounded-lg border border-gray-300 bg-white shadow-sm"
|
||||
style={{
|
||||
maxWidth: Math.min(component.width, 700),
|
||||
minHeight: Math.min(component.height, 550),
|
||||
}}
|
||||
>
|
||||
<CardRenderer
|
||||
component={component}
|
||||
getQueryResult={dummyGetQueryResult}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ComponentPreviewPanel.tsx — 범용 컴포넌트 미리보기 패널
|
||||
*
|
||||
* 모든 컴포넌트 타입의 미리보기를 통일된 레이아웃(회색 그리드 + 가운데 배치)으로 제공.
|
||||
* 카드/테이블 전용 패널을 대체하는 단일 진입점.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Eye } from "lucide-react";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
import type { QueryResult } from "../renderers/types";
|
||||
|
||||
import { TextRenderer } from "../renderers/TextRenderer";
|
||||
import { ImageRenderer } from "../renderers/ImageRenderer";
|
||||
import { DividerRenderer } from "../renderers/DividerRenderer";
|
||||
import { SignatureRenderer, StampRenderer } from "../renderers/SignatureRenderer";
|
||||
import { PageNumberRenderer } from "../renderers/PageNumberRenderer";
|
||||
import { CardRenderer } from "../renderers/CardRenderer";
|
||||
import { CalculationRenderer } from "../renderers/CalculationRenderer";
|
||||
import { BarcodeCanvasRenderer } from "../renderers/BarcodeCanvasRenderer";
|
||||
import { CheckboxRenderer } from "../renderers/CheckboxRenderer";
|
||||
import { TableRenderer } from "../renderers/TableRenderer";
|
||||
|
||||
interface ComponentPreviewPanelProps {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
const DUMMY_LAYOUT_CONFIG = {
|
||||
pages: [{ page_id: "preview_page", page_order: 1 }],
|
||||
};
|
||||
|
||||
export function ComponentPreviewPanel({ component }: ComponentPreviewPanelProps) {
|
||||
const dummyGetQueryResult = useCallback(
|
||||
(): QueryResult | null => null,
|
||||
[],
|
||||
);
|
||||
|
||||
const previewWidth = useMemo(
|
||||
() => Math.min(component.width || 700, 700),
|
||||
[component.width],
|
||||
);
|
||||
|
||||
const previewHeight = useMemo(
|
||||
() => Math.min(component.height || 400, 550),
|
||||
[component.height],
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (component.type) {
|
||||
case "text":
|
||||
case "label":
|
||||
return (
|
||||
<TextRenderer
|
||||
component={component}
|
||||
getQueryResult={dummyGetQueryResult}
|
||||
displayValue={component.content || component.text || "텍스트 미리보기"}
|
||||
/>
|
||||
);
|
||||
|
||||
case "image":
|
||||
return <ImageRenderer component={component} />;
|
||||
|
||||
case "divider":
|
||||
return <DividerRenderer component={component} />;
|
||||
|
||||
case "signature":
|
||||
return <SignatureRenderer component={component} />;
|
||||
|
||||
case "stamp":
|
||||
return <StampRenderer component={component} />;
|
||||
|
||||
case "pageNumber":
|
||||
return (
|
||||
<PageNumberRenderer
|
||||
component={component}
|
||||
currentPageId="preview_page"
|
||||
layoutConfig={DUMMY_LAYOUT_CONFIG}
|
||||
/>
|
||||
);
|
||||
|
||||
case "card":
|
||||
return (
|
||||
<CardRenderer
|
||||
component={component}
|
||||
getQueryResult={dummyGetQueryResult}
|
||||
/>
|
||||
);
|
||||
|
||||
case "table":
|
||||
return (
|
||||
<TableRenderer
|
||||
component={component}
|
||||
getQueryResult={dummyGetQueryResult}
|
||||
/>
|
||||
);
|
||||
|
||||
case "calculation":
|
||||
return (
|
||||
<CalculationRenderer
|
||||
component={component}
|
||||
getQueryResult={dummyGetQueryResult}
|
||||
/>
|
||||
);
|
||||
|
||||
case "barcode":
|
||||
return (
|
||||
<BarcodeCanvasRenderer
|
||||
component={component}
|
||||
getQueryResult={dummyGetQueryResult}
|
||||
/>
|
||||
);
|
||||
|
||||
case "checkbox":
|
||||
return (
|
||||
<CheckboxRenderer
|
||||
component={component}
|
||||
getQueryResult={dummyGetQueryResult}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-gray-400">
|
||||
미리보기를 지원하지 않는 컴포넌트입니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col items-center gap-4 p-5">
|
||||
<div className="w-full">
|
||||
<div className="mb-1 flex items-center gap-2 text-sm font-semibold text-gray-700">
|
||||
<Eye className="h-4 w-4 text-blue-600" />
|
||||
미리보기 (저장 전 상태)
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
실제 데이터는 저장 후 확인 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-h-[600px] w-full items-center justify-center overflow-auto rounded-xl border border-gray-200 bg-gray-100 p-8">
|
||||
<div
|
||||
className="overflow-hidden rounded-lg border border-gray-300 bg-white shadow-sm"
|
||||
style={{
|
||||
width: previewWidth,
|
||||
minHeight: previewHeight,
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ComponentSettingsModal.tsx
|
||||
*
|
||||
* 인캔버스 설정 모달 — 기능 설정 / 표시 조건 / 미리보기 3탭 구조.
|
||||
* SettingsModalShell 모듈을 사용하여 모든 컴포넌트가 동일한 모달 형식을 유지.
|
||||
* card 타입은 Draft 기반 저장/취소 지원.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Eye as EyeIcon, Sliders, Layers } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { SettingsModalShell, useModalAlert } from "./SettingsModalShell";
|
||||
import type { ModalTabDef } from "./SettingsModalShell";
|
||||
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
||||
|
||||
import { ConditionalSettingsTab } from "./ConditionalSettingsTab";
|
||||
import { ComponentPreviewPanel } from "./ComponentPreviewPanel";
|
||||
|
||||
import { TextProperties } from "../properties/TextProperties";
|
||||
import { ImageProperties } from "../properties/ImageProperties";
|
||||
import { TableProperties } from "../properties/TableProperties";
|
||||
import { CardProperties } from "../properties/CardProperties";
|
||||
import { CalculationProperties } from "../properties/CalculationProperties";
|
||||
import { BarcodeProperties } from "../properties/BarcodeProperties";
|
||||
import { CheckboxProperties } from "../properties/CheckboxProperties";
|
||||
import { SignatureProperties } from "../properties/SignatureProperties";
|
||||
import { PageNumberProperties } from "../properties/PageNumberProperties";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
text: "텍스트",
|
||||
label: "레이블",
|
||||
table: "테이블",
|
||||
image: "이미지",
|
||||
divider: "구분선",
|
||||
signature: "서명",
|
||||
stamp: "도장",
|
||||
pageNumber: "페이지 번호",
|
||||
card: "카드",
|
||||
calculation: "계산",
|
||||
barcode: "바코드",
|
||||
checkbox: "체크박스",
|
||||
};
|
||||
|
||||
interface DataTabProps {
|
||||
component: ComponentConfig;
|
||||
onConfigChange?: (updates: Partial<ComponentConfig>) => void;
|
||||
}
|
||||
|
||||
function DataTab({ component, onConfigChange }: DataTabProps) {
|
||||
switch (component.type) {
|
||||
case "text":
|
||||
case "label":
|
||||
return <TextProperties component={component} section="data" />;
|
||||
case "table":
|
||||
return <TableProperties component={component} section="data" />;
|
||||
case "image":
|
||||
return <ImageProperties component={component} section="data" />;
|
||||
case "signature":
|
||||
case "stamp":
|
||||
return <SignatureProperties component={component} section="data" />;
|
||||
case "pageNumber":
|
||||
return <PageNumberProperties component={component} />;
|
||||
case "card":
|
||||
return (
|
||||
<CardProperties
|
||||
component={component}
|
||||
section="data"
|
||||
onConfigChange={onConfigChange}
|
||||
/>
|
||||
);
|
||||
case "calculation":
|
||||
return <CalculationProperties component={component} section="data" />;
|
||||
case "barcode":
|
||||
return <BarcodeProperties component={component} section="data" />;
|
||||
case "checkbox":
|
||||
return <CheckboxProperties component={component} section="data" />;
|
||||
default:
|
||||
return <p className="p-4 text-sm text-gray-500">이 타입은 추가 기능 설정이 없습니다.</p>;
|
||||
}
|
||||
}
|
||||
|
||||
const TYPES_WITH_DATA_TAB = new Set([
|
||||
"text",
|
||||
"label",
|
||||
"table",
|
||||
"image",
|
||||
"signature",
|
||||
"stamp",
|
||||
"pageNumber",
|
||||
"card",
|
||||
"calculation",
|
||||
"barcode",
|
||||
"checkbox",
|
||||
]);
|
||||
|
||||
export function ComponentSettingsModal() {
|
||||
const { componentModalTargetId, closeComponentModal, components, updateComponent } = useReportDesigner();
|
||||
|
||||
const component = components.find((c) => c.id === componentModalTargetId) ?? null;
|
||||
const isDivider = component?.type === "divider";
|
||||
const isOpen = componentModalTargetId !== null && component !== null && !isDivider;
|
||||
|
||||
const [activeTab, setActiveTab] = useState("content");
|
||||
const [localDraft, setLocalDraft] = useState<ComponentConfig | null>(null);
|
||||
const { alert, clearAlert } = useModalAlert();
|
||||
const initialSnapshotRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (component) {
|
||||
setLocalDraft(component);
|
||||
initialSnapshotRef.current = JSON.stringify(component);
|
||||
clearAlert();
|
||||
const hasData = TYPES_WITH_DATA_TAB.has(component.type);
|
||||
setActiveTab(hasData ? "content" : "preview");
|
||||
}
|
||||
}, [componentModalTargetId, clearAlert]);
|
||||
|
||||
const hasChanges = useCallback(() => {
|
||||
if (component?.type === "card") {
|
||||
if (!localDraft) return false;
|
||||
return JSON.stringify(localDraft) !== initialSnapshotRef.current;
|
||||
}
|
||||
if (!component) return false;
|
||||
return JSON.stringify(component) !== initialSnapshotRef.current;
|
||||
}, [localDraft, component]);
|
||||
|
||||
const isSavingRef = useRef(false);
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
hasChanges,
|
||||
onClose: () => {
|
||||
if (!isSavingRef.current && initialSnapshotRef.current && component) {
|
||||
const original = JSON.parse(initialSnapshotRef.current) as ComponentConfig;
|
||||
updateComponent(component.id, original);
|
||||
}
|
||||
isSavingRef.current = false;
|
||||
setLocalDraft(null);
|
||||
clearAlert();
|
||||
closeComponentModal();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (component?.type === "card" && localDraft) {
|
||||
updateComponent(localDraft.id, localDraft);
|
||||
}
|
||||
isSavingRef.current = true;
|
||||
initialSnapshotRef.current = component?.type === "card" && localDraft
|
||||
? JSON.stringify(localDraft)
|
||||
: JSON.stringify(component);
|
||||
guard.doClose();
|
||||
}, [component, localDraft, updateComponent, guard]);
|
||||
|
||||
const handleDraftChange = useCallback(
|
||||
(updates: Partial<ComponentConfig>) => {
|
||||
setLocalDraft((prev) => {
|
||||
if (!prev && component) return { ...component, ...updates };
|
||||
if (!prev) return null;
|
||||
return { ...prev, ...updates };
|
||||
});
|
||||
},
|
||||
[component],
|
||||
);
|
||||
|
||||
if (!component) return null;
|
||||
|
||||
const hasDataTab = TYPES_WITH_DATA_TAB.has(component.type);
|
||||
const typeLabel = TYPE_LABELS[component.type] ?? component.type;
|
||||
const isCard = component.type === "card";
|
||||
const isTable = component.type === "table";
|
||||
const isText = component.type === "text" || component.type === "label";
|
||||
const isImage = component.type === "image";
|
||||
const hideConditionTab = isText || isImage || isDivider;
|
||||
const hasInternalConditionTab = isCard || isTable || hideConditionTab;
|
||||
const displayComponent = isCard && localDraft ? localDraft : component;
|
||||
|
||||
const tabs: ModalTabDef[] = [
|
||||
hasDataTab && {
|
||||
key: "content",
|
||||
icon: <Sliders className="h-4 w-4" />,
|
||||
label: "기능 설정",
|
||||
},
|
||||
!hasInternalConditionTab && {
|
||||
key: "conditional",
|
||||
icon: <EyeIcon className="h-4 w-4" />,
|
||||
label: "표시 조건",
|
||||
},
|
||||
{
|
||||
key: "preview",
|
||||
icon: <EyeIcon className="h-4 w-4" />,
|
||||
label: "미리보기",
|
||||
},
|
||||
].filter(Boolean) as ModalTabDef[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsModalShell
|
||||
open={isOpen}
|
||||
onOpenChange={guard.handleOpenChange}
|
||||
title={`${typeLabel} 설정`}
|
||||
icon={<Layers className="h-5 w-5" />}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onSave={handleSave}
|
||||
onClose={guard.tryClose}
|
||||
alert={alert}
|
||||
>
|
||||
{activeTab === "content" && hasDataTab && (
|
||||
<DataTab
|
||||
component={displayComponent}
|
||||
onConfigChange={isCard ? handleDraftChange : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "conditional" && !hasInternalConditionTab && (
|
||||
<ConditionalSettingsTab component={component} />
|
||||
)}
|
||||
|
||||
{activeTab === "preview" && (
|
||||
<ComponentPreviewPanel component={displayComponent} />
|
||||
)}
|
||||
</SettingsModalShell>
|
||||
<UnsavedChangesDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ConditionalSettingsTab.tsx
|
||||
*
|
||||
* 조건부 표시 탭 — ConditionalProperties를 모달 탭으로 래핑한다.
|
||||
*
|
||||
* [사용처]
|
||||
* - ComponentSettingsModal의 "조건부 표시" 탭
|
||||
* - ConditionalProperties는 `component` prop을 받고 내부에서
|
||||
* updateComponent(context)를 호출하므로 추가 연결 없이 동작한다.
|
||||
*/
|
||||
|
||||
import { ConditionalProperties } from "../properties/ConditionalProperties";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
export function ConditionalSettingsTab({ component }: Props) {
|
||||
return <ConditionalProperties component={component} />;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* FooterAggregateModal — 테이블 푸터 집계 설정 모달
|
||||
*
|
||||
* design-system.md Shell 패턴 적용.
|
||||
* 특정 열을 클릭하면 열리며, 해당 열의 집계 유형(합계/평균/개수/수식)을 설정.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUnsavedChangesGuard, UnsavedChangesDialog } from "@/components/common/UnsavedChangesGuard";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Calculator, X } from "lucide-react";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
type TableColumn = NonNullable<ComponentConfig["tableColumns"]>[number];
|
||||
|
||||
interface FooterAggregateModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
column: TableColumn | null;
|
||||
columnIndex: number;
|
||||
onSave: (idx: number, updates: Partial<TableColumn>) => void;
|
||||
}
|
||||
|
||||
export function FooterAggregateModal({ open, onOpenChange, column, columnIndex, onSave }: FooterAggregateModalProps) {
|
||||
const [summaryType, setSummaryType] = useState<"SUM" | "AVG" | "COUNT" | "NONE">("NONE");
|
||||
const [formula, setFormula] = useState("");
|
||||
const initialSnapshotRef = useRef<string>("");
|
||||
|
||||
const hasChanges = useCallback(() => {
|
||||
const current = JSON.stringify({ summaryType, formula });
|
||||
return current !== initialSnapshotRef.current;
|
||||
}, [summaryType, formula]);
|
||||
|
||||
const guard = useUnsavedChangesGuard({
|
||||
hasChanges,
|
||||
onClose: () => onOpenChange(false),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (column) {
|
||||
const initType = column.summaryType || "NONE";
|
||||
const initFormula = column.formula || "";
|
||||
setSummaryType(initType);
|
||||
setFormula(initFormula);
|
||||
initialSnapshotRef.current = JSON.stringify({ summaryType: initType, formula: initFormula });
|
||||
}
|
||||
}, [column]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(columnIndex, {
|
||||
summaryType,
|
||||
formula: summaryType === "NONE" ? undefined : formula || undefined,
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
if (!column) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={guard.handleOpenChange}>
|
||||
<DialogContent className="flex h-auto max-w-lg flex-col overflow-hidden p-0 [&>button]:hidden">
|
||||
<DialogTitle className="sr-only">계산 방식 설정</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{column.field || column.header} 열의 계산 방식을 설정합니다
|
||||
</DialogDescription>
|
||||
|
||||
{/* Header */}
|
||||
<div className="border-border flex items-center justify-between border-b px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="h-4 w-4 text-blue-600" />
|
||||
<h2 className="text-foreground text-base font-semibold">계산 방식 설정</h2>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={guard.tryClose} className="h-8 w-8">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-3 px-6 py-4">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-mono font-medium text-blue-700">{column.field || column.header}</span> 열의 계산
|
||||
방식을 설정합니다.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-foreground text-xs font-medium">계산 방식 *</Label>
|
||||
<Select value={summaryType} onValueChange={(v) => setSummaryType(v as typeof summaryType)}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="NONE">없음</SelectItem>
|
||||
<SelectItem value="SUM">합계</SelectItem>
|
||||
<SelectItem value="AVG">평균</SelectItem>
|
||||
<SelectItem value="COUNT">개수</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{summaryType !== "NONE" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-foreground text-xs font-medium">계산식 (선택)</Label>
|
||||
<Input
|
||||
value={formula}
|
||||
onChange={(e) => setFormula(e.target.value)}
|
||||
placeholder="예: {price} * {qty}"
|
||||
className="h-9 font-mono text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground text-[10px]">비워두면 기본 계산이 적용됩니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-border flex items-center justify-end gap-2 border-t px-6 py-4">
|
||||
<Button variant="outline" onClick={guard.tryClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleSave}>
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<UnsavedChangesDialog guard={guard} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* GridCellDropZone — 그리드 양식의 데이터 연결 드롭 존
|
||||
*
|
||||
* 모든 셀에 드롭 가능. 헤더 영역(좌측/상단)은 배경색으로 시각 구분.
|
||||
* 푸터 영역(하단)은 요약 집계 셀로 시각 구분.
|
||||
* - 헤더 영역 드롭 → 고정 라벨(컬럼명)로 설정
|
||||
* - 데이터 영역 드롭 → 데이터 바인딩(field)으로 설정
|
||||
* - 푸터 영역 → 요약(집계) 타입 설정 가능
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { X, Database, Type, Calculator } from "lucide-react";
|
||||
import { TABLE_COLUMN_DND_TYPE } from "./TableColumnPalette";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { GridCell } from "@/types/report";
|
||||
|
||||
interface PaletteDragItem {
|
||||
columnName: string;
|
||||
dataType: string;
|
||||
}
|
||||
|
||||
interface GridCellDropZoneProps {
|
||||
cells: GridCell[];
|
||||
rowCount: number;
|
||||
colCount: number;
|
||||
colWidths: number[];
|
||||
rowHeights: number[];
|
||||
headerRows?: number;
|
||||
headerCols?: number;
|
||||
footerRows?: number;
|
||||
onCellDrop: (row: number, col: number, columnName: string) => void;
|
||||
onHeaderDrop: (row: number, col: number, columnName: string) => void;
|
||||
onCellClear: (row: number, col: number) => void;
|
||||
onFooterCellClick?: (row: number, col: number) => void;
|
||||
}
|
||||
|
||||
const SUMMARY_LABELS: Record<string, string> = {
|
||||
SUM: "합계",
|
||||
AVG: "평균",
|
||||
COUNT: "개수",
|
||||
};
|
||||
|
||||
// ─── 통합 드롭 셀 ───────────────────────────────────────────────────────────
|
||||
|
||||
interface DropCellProps {
|
||||
cell: GridCell;
|
||||
width: number;
|
||||
height: number;
|
||||
isHeader: boolean;
|
||||
isFooter: boolean;
|
||||
onDrop: (columnName: string) => void;
|
||||
onClear: () => void;
|
||||
onFooterClick?: () => void;
|
||||
}
|
||||
|
||||
function DropCell({ cell, width, height, isHeader, isFooter, onDrop, onClear, onFooterClick }: DropCellProps) {
|
||||
const isField = cell.cellType === "field" && !!cell.field;
|
||||
const isLabel = cell.cellType === "static" && !!cell.value;
|
||||
const hasSummary = !!cell.summaryType && cell.summaryType !== "NONE";
|
||||
|
||||
const [{ isOver, canDrop }, drop] = useDrop(
|
||||
() => ({
|
||||
accept: TABLE_COLUMN_DND_TYPE,
|
||||
drop: (item: PaletteDragItem) => onDrop(item.columnName),
|
||||
canDrop: () => !cell.merged,
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
}),
|
||||
[cell, onDrop],
|
||||
);
|
||||
|
||||
if (cell.merged) return null;
|
||||
|
||||
const rSpan = cell.rowSpan ?? 1;
|
||||
const cSpan = cell.colSpan ?? 1;
|
||||
|
||||
let bg = isHeader ? "#f3f4f6" : isFooter ? "#f8fafc" : "white";
|
||||
if (isOver && canDrop) bg = "#dbeafe";
|
||||
else if (hasSummary && isFooter) bg = "#eff6ff";
|
||||
else if (isField) bg = "#eff6ff";
|
||||
else if (isLabel && isHeader) bg = "#f0fdf4";
|
||||
|
||||
const renderContent = () => {
|
||||
if (isFooter && hasSummary) {
|
||||
return (
|
||||
<div className="flex cursor-pointer items-center justify-center gap-1" onClick={onFooterClick}>
|
||||
<Calculator className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="text-[10px] font-semibold text-blue-700">
|
||||
{SUMMARY_LABELS[cell.summaryType!] || cell.summaryType}
|
||||
</span>
|
||||
{cell.field && <span className="truncate text-[10px] text-blue-400">({cell.field})</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isFooter && !isField && !isLabel) {
|
||||
return (
|
||||
<span
|
||||
className="block cursor-pointer text-center text-[10px] text-gray-400 hover:text-blue-600"
|
||||
onClick={onFooterClick}
|
||||
>
|
||||
클릭하여 설정
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (isField) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Database className="h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="truncate text-xs font-medium text-blue-700">{cell.field}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded text-gray-400 hover:bg-red-100 hover:text-red-500"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLabel) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Type className="h-3 w-3 shrink-0 text-green-600" />
|
||||
<span className="truncate text-xs font-semibold text-gray-700">{cell.value}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center rounded text-gray-400 hover:bg-red-100 hover:text-red-500"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="block text-center text-xs text-gray-300">—</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<td
|
||||
ref={(node) => {
|
||||
drop(node);
|
||||
}}
|
||||
rowSpan={rSpan > 1 ? rSpan : undefined}
|
||||
colSpan={cSpan > 1 ? cSpan : undefined}
|
||||
className={`border ${isHeader ? "border-gray-300" : isFooter ? "border-blue-200" : "border-gray-200"}`}
|
||||
style={{
|
||||
width,
|
||||
height: Math.max(height, 32),
|
||||
minWidth: width,
|
||||
backgroundColor: bg,
|
||||
padding: "3px 6px",
|
||||
verticalAlign: "middle",
|
||||
transition: "background-color 150ms",
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 컴포넌트 ──────────────────────────────────────────────────────────
|
||||
|
||||
export function GridCellDropZone({
|
||||
cells,
|
||||
rowCount,
|
||||
colCount,
|
||||
colWidths,
|
||||
rowHeights,
|
||||
headerRows = 1,
|
||||
headerCols = 1,
|
||||
footerRows = 0,
|
||||
onCellDrop,
|
||||
onHeaderDrop,
|
||||
onCellClear,
|
||||
onFooterCellClick,
|
||||
}: GridCellDropZoneProps) {
|
||||
const totalWidth = colWidths.reduce((a, b) => a + b, 0);
|
||||
|
||||
const findCell = (row: number, col: number) => cells.find((c) => c.row === row && c.col === col);
|
||||
|
||||
if (cells.length === 0 || rowCount === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed border-gray-300 py-8 text-xs text-gray-400">
|
||||
레이아웃 탭에서 격자를 먼저 구성하세요.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldCount = cells.filter(
|
||||
(c) => (c.cellType === "field" || (c.cellType === "static" && c.value)) && !c.merged,
|
||||
).length;
|
||||
const totalNonMerged = cells.filter((c) => !c.merged).length;
|
||||
const footerStartRow = rowCount - footerRows;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
|
||||
<span className="text-sm font-bold text-gray-800">열 격자 배치</span>
|
||||
<span className="text-[10px] text-gray-400">{fieldCount}/{totalNonMerged} 배치됨</span>
|
||||
</div>
|
||||
<div className="overflow-auto p-3">
|
||||
<table className="border-collapse" style={{ width: totalWidth, tableLayout: "fixed" }}>
|
||||
<colgroup>
|
||||
{colWidths.map((w, i) => (
|
||||
<col key={i} style={{ width: w }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{Array.from({ length: rowCount }).map((_, r) => {
|
||||
const tds: React.ReactNode[] = [];
|
||||
for (let c = 0; c < colCount; c++) {
|
||||
const cell = findCell(r, c);
|
||||
if (!cell || cell.merged) continue;
|
||||
|
||||
const cSpan = cell.colSpan ?? 1;
|
||||
const rSpan = cell.rowSpan ?? 1;
|
||||
const w = colWidths.slice(c, c + cSpan).reduce((a, b) => a + b, 0);
|
||||
const h = rowHeights.slice(r, r + rSpan).reduce((a, b) => a + b, 0);
|
||||
const isHeader = r < headerRows || c < headerCols;
|
||||
const isFooter = footerRows > 0 && r >= footerStartRow;
|
||||
|
||||
const cellNode = (
|
||||
<DropCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
width={w}
|
||||
height={h}
|
||||
isHeader={isHeader}
|
||||
isFooter={isFooter}
|
||||
onDrop={(columnName) =>
|
||||
isHeader ? onHeaderDrop(r, c, columnName) : onCellDrop(r, c, columnName)
|
||||
}
|
||||
onClear={() => onCellClear(r, c)}
|
||||
onFooterClick={isFooter && onFooterCellClick ? () => onFooterCellClick(r, c) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isFooter) {
|
||||
tds.push(
|
||||
<Tooltip key={cell.id}>
|
||||
<TooltipTrigger asChild>{cellNode}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[200px]">
|
||||
<p className="text-xs">
|
||||
{cell.summaryType && cell.summaryType !== "NONE"
|
||||
? `${SUMMARY_LABELS[cell.summaryType] || cell.summaryType}${cell.field ? ` (${cell.field})` : ""}`
|
||||
: "클릭하여 계산 방식을 선택하세요"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>,
|
||||
);
|
||||
} else {
|
||||
tds.push(cellNode);
|
||||
}
|
||||
}
|
||||
return <tr key={`drop-row-${r}`}>{tds}</tr>;
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,897 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* GridEditor — 복잡한 테이블 양식 편집기
|
||||
*
|
||||
* 셀 병합(rowspan/colspan), 고정 텍스트/데이터 바인딩 혼합,
|
||||
* 셀별 스타일 설정이 가능한 그리드 에디터.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Plus,
|
||||
Minus,
|
||||
Merge,
|
||||
SplitSquareHorizontal,
|
||||
Type,
|
||||
Database,
|
||||
Paintbrush,
|
||||
Bold,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
Rows3,
|
||||
Columns3,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { GridCell, ComponentConfig } from "@/types/report";
|
||||
|
||||
// ─── 상수 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_COL_WIDTH = 100;
|
||||
const DEFAULT_ROW_HEIGHT = 32;
|
||||
const MIN_ROWS = 1;
|
||||
const MIN_COLS = 1;
|
||||
const MAX_ROWS = 100;
|
||||
const MAX_COLS = 30;
|
||||
const INITIAL_ROWS = 4;
|
||||
const INITIAL_COLS = 6;
|
||||
|
||||
// ─── 유틸 함수 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function cellId(row: number, col: number): string {
|
||||
return `r${row}c${col}`;
|
||||
}
|
||||
|
||||
function createEmptyCell(row: number, col: number): GridCell {
|
||||
return {
|
||||
id: cellId(row, col),
|
||||
row,
|
||||
col,
|
||||
rowSpan: 1,
|
||||
colSpan: 1,
|
||||
cellType: "static",
|
||||
value: "",
|
||||
align: "center",
|
||||
verticalAlign: "middle",
|
||||
fontWeight: "normal",
|
||||
fontSize: 12,
|
||||
borderStyle: "thin",
|
||||
};
|
||||
}
|
||||
|
||||
function initGrid(rows: number, cols: number): GridCell[] {
|
||||
const cells: GridCell[] = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
cells.push(createEmptyCell(r, c));
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function getCell(cells: GridCell[], row: number, col: number): GridCell | undefined {
|
||||
return cells.find((c) => c.row === row && c.col === col);
|
||||
}
|
||||
|
||||
interface SelectionRange {
|
||||
startRow: number;
|
||||
startCol: number;
|
||||
endRow: number;
|
||||
endCol: number;
|
||||
}
|
||||
|
||||
function normalizeRange(range: SelectionRange): SelectionRange {
|
||||
return {
|
||||
startRow: Math.min(range.startRow, range.endRow),
|
||||
startCol: Math.min(range.startCol, range.endCol),
|
||||
endRow: Math.max(range.startRow, range.endRow),
|
||||
endCol: Math.max(range.startCol, range.endCol),
|
||||
};
|
||||
}
|
||||
|
||||
function isCellInRange(row: number, col: number, range: SelectionRange | null): boolean {
|
||||
if (!range) return false;
|
||||
const n = normalizeRange(range);
|
||||
return row >= n.startRow && row <= n.endRow && col >= n.startCol && col <= n.endCol;
|
||||
}
|
||||
|
||||
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GridEditorProps {
|
||||
component: ComponentConfig;
|
||||
onUpdate: (updates: Partial<ComponentConfig>) => void;
|
||||
schemaColumns?: Array<{ column_name: string; data_type: string }>;
|
||||
}
|
||||
|
||||
// ─── 메인 컴포넌트 ──────────────────────────────────────────────────────────
|
||||
|
||||
export function GridEditor({ component, onUpdate, schemaColumns = [] }: GridEditorProps) {
|
||||
const rowCount = component.gridRowCount ?? INITIAL_ROWS;
|
||||
const colCount = component.gridColCount ?? INITIAL_COLS;
|
||||
const cells = component.gridCells ?? initGrid(rowCount, colCount);
|
||||
const colWidths = component.gridColWidths ?? Array(colCount).fill(DEFAULT_COL_WIDTH);
|
||||
const rowHeights = component.gridRowHeights ?? Array(rowCount).fill(DEFAULT_ROW_HEIGHT);
|
||||
|
||||
const [selection, setSelection] = useState<SelectionRange | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef<{ row: number; col: number } | null>(null);
|
||||
|
||||
// 열/행 리사이즈
|
||||
const [resizingColIdx, setResizingColIdx] = useState<number | null>(null);
|
||||
const [resizingRowIdx, setResizingRowIdx] = useState<number | null>(null);
|
||||
const resizeStartRef = useRef(0);
|
||||
const resizeStartSizeRef = useRef(0);
|
||||
|
||||
// 선택된 단일 셀 (설정 패널용)
|
||||
const selectedCell = useMemo(() => {
|
||||
if (!selection) return null;
|
||||
const n = normalizeRange(selection);
|
||||
return getCell(cells, n.startRow, n.startCol);
|
||||
}, [selection, cells]);
|
||||
|
||||
// 선택 범위 내 셀 개수
|
||||
const selectedCellCount = useMemo(() => {
|
||||
if (!selection) return 0;
|
||||
const n = normalizeRange(selection);
|
||||
let count = 0;
|
||||
for (let r = n.startRow; r <= n.endRow; r++) {
|
||||
for (let c = n.startCol; c <= n.endCol; c++) {
|
||||
const cell = getCell(cells, r, c);
|
||||
if (cell && !cell.merged) count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}, [selection, cells]);
|
||||
|
||||
// ─── 그리드 업데이트 헬퍼 ─────────────────────────────────────────────────
|
||||
|
||||
const updateGrid = useCallback(
|
||||
(newCells: GridCell[], newRowCount?: number, newColCount?: number, newColWidths?: number[], newRowHeights?: number[]) => {
|
||||
onUpdate({
|
||||
gridCells: newCells,
|
||||
gridRowCount: newRowCount ?? rowCount,
|
||||
gridColCount: newColCount ?? colCount,
|
||||
gridColWidths: newColWidths ?? colWidths,
|
||||
gridRowHeights: newRowHeights ?? rowHeights,
|
||||
});
|
||||
},
|
||||
[onUpdate, rowCount, colCount, colWidths, rowHeights],
|
||||
);
|
||||
|
||||
const updateCellProps = useCallback(
|
||||
(row: number, col: number, updates: Partial<GridCell>) => {
|
||||
const newCells = cells.map((c) =>
|
||||
c.row === row && c.col === col ? { ...c, ...updates } : c,
|
||||
);
|
||||
updateGrid(newCells);
|
||||
},
|
||||
[cells, updateGrid],
|
||||
);
|
||||
|
||||
// ─── 셀 선택 핸들러 ──────────────────────────────────────────────────────
|
||||
|
||||
const handleCellMouseDown = useCallback(
|
||||
(row: number, col: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
// 병합된 셀 클릭 시 주체 셀로 리다이렉트
|
||||
const cell = getCell(cells, row, col);
|
||||
if (cell?.merged && cell.mergedBy) {
|
||||
const master = cells.find((c) => c.id === cell.mergedBy);
|
||||
if (master) {
|
||||
row = master.row;
|
||||
col = master.col;
|
||||
}
|
||||
}
|
||||
|
||||
dragStartRef.current = { row, col };
|
||||
setIsDragging(true);
|
||||
setSelection({ startRow: row, startCol: col, endRow: row, endCol: col });
|
||||
},
|
||||
[cells],
|
||||
);
|
||||
|
||||
const handleCellMouseEnter = useCallback(
|
||||
(row: number, col: number) => {
|
||||
if (!isDragging || !dragStartRef.current) return;
|
||||
setSelection({
|
||||
startRow: dragStartRef.current.row,
|
||||
startCol: dragStartRef.current.col,
|
||||
endRow: row,
|
||||
endCol: col,
|
||||
});
|
||||
},
|
||||
[isDragging],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
}, []);
|
||||
|
||||
// ─── 행/열 추가/삭제 ─────────────────────────────────────────────────────
|
||||
|
||||
const handleAddRow = useCallback(() => {
|
||||
if (rowCount >= MAX_ROWS) return;
|
||||
const newRow = rowCount;
|
||||
const newCells = [
|
||||
...cells,
|
||||
...Array.from({ length: colCount }, (_, c) => createEmptyCell(newRow, c)),
|
||||
];
|
||||
updateGrid(newCells, newRow + 1, colCount, colWidths, [...rowHeights, DEFAULT_ROW_HEIGHT]);
|
||||
}, [cells, rowCount, colCount, colWidths, rowHeights, updateGrid]);
|
||||
|
||||
const handleAddCol = useCallback(() => {
|
||||
if (colCount >= MAX_COLS) return;
|
||||
const newCol = colCount;
|
||||
const newCells = [
|
||||
...cells,
|
||||
...Array.from({ length: rowCount }, (_, r) => createEmptyCell(r, newCol)),
|
||||
];
|
||||
updateGrid(newCells, rowCount, newCol + 1, [...colWidths, DEFAULT_COL_WIDTH], rowHeights);
|
||||
}, [cells, rowCount, colCount, colWidths, rowHeights, updateGrid]);
|
||||
|
||||
const handleRemoveRow = useCallback(() => {
|
||||
if (rowCount <= MIN_ROWS) return;
|
||||
const lastRow = rowCount - 1;
|
||||
// 병합이 마지막 행을 포함하면 제거 불가
|
||||
const hasMergeConflict = cells.some(
|
||||
(c) => !c.merged && c.row < lastRow && c.row + (c.rowSpan ?? 1) - 1 >= lastRow,
|
||||
);
|
||||
if (hasMergeConflict) return;
|
||||
|
||||
const newCells = cells.filter((c) => c.row < lastRow);
|
||||
updateGrid(newCells, lastRow, colCount, colWidths, rowHeights.slice(0, lastRow));
|
||||
}, [cells, rowCount, colCount, colWidths, rowHeights, updateGrid]);
|
||||
|
||||
const handleRemoveCol = useCallback(() => {
|
||||
if (colCount <= MIN_COLS) return;
|
||||
const lastCol = colCount - 1;
|
||||
const hasMergeConflict = cells.some(
|
||||
(c) => !c.merged && c.col < lastCol && c.col + (c.colSpan ?? 1) - 1 >= lastCol,
|
||||
);
|
||||
if (hasMergeConflict) return;
|
||||
|
||||
const newCells = cells.filter((c) => c.col < lastCol);
|
||||
updateGrid(newCells, rowCount, lastCol, colWidths.slice(0, lastCol), rowHeights);
|
||||
}, [cells, rowCount, colCount, colWidths, rowHeights, updateGrid]);
|
||||
|
||||
const handleRemoveColAt = useCallback(
|
||||
(targetCol: number) => {
|
||||
if (colCount <= MIN_COLS) return;
|
||||
|
||||
const hasMergeConflict = cells.some((c) => {
|
||||
if (c.merged) return false;
|
||||
const cStart = c.col;
|
||||
const cEnd = c.col + (c.colSpan ?? 1) - 1;
|
||||
return cStart !== targetCol && cEnd >= targetCol && cStart < targetCol;
|
||||
});
|
||||
if (hasMergeConflict) return;
|
||||
|
||||
const newCells = cells
|
||||
.filter((c) => c.col !== targetCol)
|
||||
.map((c) => {
|
||||
const newCol = c.col > targetCol ? c.col - 1 : c.col;
|
||||
return {
|
||||
...c,
|
||||
col: newCol,
|
||||
id: cellId(c.row, newCol),
|
||||
...(c.mergedBy
|
||||
? {
|
||||
mergedBy: cells.find((m) => m.id === c.mergedBy)
|
||||
? cellId(
|
||||
cells.find((m) => m.id === c.mergedBy)!.row,
|
||||
cells.find((m) => m.id === c.mergedBy)!.col > targetCol
|
||||
? cells.find((m) => m.id === c.mergedBy)!.col - 1
|
||||
: cells.find((m) => m.id === c.mergedBy)!.col,
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
const newColWidths = colWidths.filter((_, i) => i !== targetCol);
|
||||
updateGrid(newCells, rowCount, colCount - 1, newColWidths, rowHeights);
|
||||
setSelection(null);
|
||||
},
|
||||
[cells, rowCount, colCount, colWidths, rowHeights, updateGrid],
|
||||
);
|
||||
|
||||
const handleRemoveRowAt = useCallback(
|
||||
(targetRow: number) => {
|
||||
if (rowCount <= MIN_ROWS) return;
|
||||
|
||||
const hasMergeConflict = cells.some((c) => {
|
||||
if (c.merged) return false;
|
||||
const rStart = c.row;
|
||||
const rEnd = c.row + (c.rowSpan ?? 1) - 1;
|
||||
return rStart !== targetRow && rEnd >= targetRow && rStart < targetRow;
|
||||
});
|
||||
if (hasMergeConflict) return;
|
||||
|
||||
const newCells = cells
|
||||
.filter((c) => c.row !== targetRow)
|
||||
.map((c) => {
|
||||
const newRow = c.row > targetRow ? c.row - 1 : c.row;
|
||||
return {
|
||||
...c,
|
||||
row: newRow,
|
||||
id: cellId(newRow, c.col),
|
||||
...(c.mergedBy
|
||||
? {
|
||||
mergedBy: cells.find((m) => m.id === c.mergedBy)
|
||||
? cellId(
|
||||
cells.find((m) => m.id === c.mergedBy)!.row > targetRow
|
||||
? cells.find((m) => m.id === c.mergedBy)!.row - 1
|
||||
: cells.find((m) => m.id === c.mergedBy)!.row,
|
||||
cells.find((m) => m.id === c.mergedBy)!.col,
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
|
||||
const newRowHeights = rowHeights.filter((_, i) => i !== targetRow);
|
||||
updateGrid(newCells, rowCount - 1, colCount, colWidths, newRowHeights);
|
||||
setSelection(null);
|
||||
},
|
||||
[cells, rowCount, colCount, colWidths, rowHeights, updateGrid],
|
||||
);
|
||||
|
||||
// ─── 셀 병합 / 해제 ──────────────────────────────────────────────────────
|
||||
|
||||
const canMerge = useMemo(() => {
|
||||
if (!selection) return false;
|
||||
const n = normalizeRange(selection);
|
||||
if (n.startRow === n.endRow && n.startCol === n.endCol) return false;
|
||||
// 선택 범위 안에 이미 병합된 셀이 있으면 불가
|
||||
for (let r = n.startRow; r <= n.endRow; r++) {
|
||||
for (let c = n.startCol; c <= n.endCol; c++) {
|
||||
const cell = getCell(cells, r, c);
|
||||
if (!cell) return false;
|
||||
if (cell.merged) return false;
|
||||
if ((cell.rowSpan ?? 1) > 1 || (cell.colSpan ?? 1) > 1) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}, [selection, cells]);
|
||||
|
||||
const canUnmerge = useMemo(() => {
|
||||
if (!selectedCell) return false;
|
||||
return (selectedCell.rowSpan ?? 1) > 1 || (selectedCell.colSpan ?? 1) > 1;
|
||||
}, [selectedCell]);
|
||||
|
||||
const handleMerge = useCallback(() => {
|
||||
if (!selection || !canMerge) return;
|
||||
const n = normalizeRange(selection);
|
||||
const rSpan = n.endRow - n.startRow + 1;
|
||||
const cSpan = n.endCol - n.startCol + 1;
|
||||
const masterId = cellId(n.startRow, n.startCol);
|
||||
|
||||
const newCells = cells.map((cell) => {
|
||||
if (cell.row === n.startRow && cell.col === n.startCol) {
|
||||
return { ...cell, rowSpan: rSpan, colSpan: cSpan };
|
||||
}
|
||||
if (isCellInRange(cell.row, cell.col, n) && !(cell.row === n.startRow && cell.col === n.startCol)) {
|
||||
return { ...cell, merged: true, mergedBy: masterId, value: "", field: "", formula: "" };
|
||||
}
|
||||
return cell;
|
||||
});
|
||||
|
||||
updateGrid(newCells);
|
||||
setSelection({ startRow: n.startRow, startCol: n.startCol, endRow: n.startRow, endCol: n.startCol });
|
||||
}, [selection, canMerge, cells, updateGrid]);
|
||||
|
||||
const handleUnmerge = useCallback(() => {
|
||||
if (!selectedCell || !canUnmerge) return;
|
||||
const masterId = selectedCell.id;
|
||||
|
||||
const newCells = cells.map((cell) => {
|
||||
if (cell.id === masterId) {
|
||||
return { ...cell, rowSpan: 1, colSpan: 1 };
|
||||
}
|
||||
if (cell.mergedBy === masterId) {
|
||||
return { ...cell, merged: false, mergedBy: undefined };
|
||||
}
|
||||
return cell;
|
||||
});
|
||||
|
||||
updateGrid(newCells);
|
||||
}, [selectedCell, canUnmerge, cells, updateGrid]);
|
||||
|
||||
// ─── 열/행 리사이즈 ────────────────────────────────────────────────────────
|
||||
|
||||
const MIN_COL_WIDTH = 40;
|
||||
const MIN_ROW_HEIGHT = 20;
|
||||
|
||||
const handleColResizeStart = useCallback(
|
||||
(colIdx: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setResizingColIdx(colIdx);
|
||||
resizeStartRef.current = e.clientX;
|
||||
resizeStartSizeRef.current = colWidths[colIdx] ?? DEFAULT_COL_WIDTH;
|
||||
|
||||
const onMove = (moveEvent: MouseEvent) => {
|
||||
const delta = moveEvent.clientX - resizeStartRef.current;
|
||||
const newWidth = Math.max(MIN_COL_WIDTH, resizeStartSizeRef.current + delta);
|
||||
const newColWidths = colWidths.map((w, i) => (i === colIdx ? newWidth : w));
|
||||
updateGrid(cells, rowCount, colCount, newColWidths, rowHeights);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
setResizingColIdx(null);
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[colWidths, cells, rowCount, colCount, rowHeights, updateGrid],
|
||||
);
|
||||
|
||||
const handleRowResizeStart = useCallback(
|
||||
(rowIdx: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setResizingRowIdx(rowIdx);
|
||||
resizeStartRef.current = e.clientY;
|
||||
resizeStartSizeRef.current = rowHeights[rowIdx] ?? DEFAULT_ROW_HEIGHT;
|
||||
|
||||
const onMove = (moveEvent: MouseEvent) => {
|
||||
const delta = moveEvent.clientY - resizeStartRef.current;
|
||||
const newHeight = Math.max(MIN_ROW_HEIGHT, resizeStartSizeRef.current + delta);
|
||||
const newRowHeights = rowHeights.map((h, i) => (i === rowIdx ? newHeight : h));
|
||||
updateGrid(cells, rowCount, colCount, colWidths, newRowHeights);
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
setResizingRowIdx(null);
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
},
|
||||
[rowHeights, cells, rowCount, colCount, colWidths, updateGrid],
|
||||
);
|
||||
|
||||
// ─── 셀 직접 편집 (더블클릭) ──────────────────────────────────────────────
|
||||
|
||||
const [editingCell, setEditingCell] = useState<string | null>(null);
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleCellDoubleClick = useCallback((cell: GridCell) => {
|
||||
if (cell.merged) return;
|
||||
setEditingCell(cell.id);
|
||||
setTimeout(() => editInputRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
const handleEditBlur = useCallback(
|
||||
(cell: GridCell, newValue: string) => {
|
||||
setEditingCell(null);
|
||||
if (cell.cellType === "static") {
|
||||
updateCellProps(cell.row, cell.col, { value: newValue });
|
||||
} else if (cell.cellType === "field") {
|
||||
updateCellProps(cell.row, cell.col, { field: newValue });
|
||||
}
|
||||
},
|
||||
[updateCellProps],
|
||||
);
|
||||
|
||||
const handleEditKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, cell: GridCell, value: string) => {
|
||||
if (e.key === "Enter") {
|
||||
handleEditBlur(cell, value);
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingCell(null);
|
||||
}
|
||||
},
|
||||
[handleEditBlur],
|
||||
);
|
||||
|
||||
// ─── 그리드 렌더링 ────────────────────────────────────────────────────────
|
||||
|
||||
const totalWidth = colWidths.reduce((a, b) => a + b, 0);
|
||||
|
||||
const renderGridRow = (r: number): React.ReactNode[] => {
|
||||
const tds: React.ReactNode[] = [];
|
||||
|
||||
for (let c = 0; c < colCount; c++) {
|
||||
const cell = getCell(cells, r, c);
|
||||
if (!cell || cell.merged) continue;
|
||||
|
||||
const rSpan = cell.rowSpan ?? 1;
|
||||
const cSpan = cell.colSpan ?? 1;
|
||||
const isSelected = isCellInRange(r, c, selection);
|
||||
const isEditing = editingCell === cell.id;
|
||||
|
||||
const w = colWidths.slice(c, c + cSpan).reduce((a, b) => a + b, 0);
|
||||
const h = rowHeights.slice(r, r + rSpan).reduce((a, b) => a + b, 0);
|
||||
|
||||
const cellBg = cell.backgroundColor || (isSelected ? "#dbeafe" : "white");
|
||||
const borderW = cell.borderStyle === "medium" ? 2 : cell.borderStyle === "thick" ? 3 : cell.borderStyle === "none" ? 0 : 1;
|
||||
|
||||
const displayValue =
|
||||
cell.cellType === "field" && cell.field
|
||||
? `{${cell.field}}`
|
||||
: cell.cellType === "formula" && cell.formula
|
||||
? `=${cell.formula}`
|
||||
: cell.value ?? "";
|
||||
|
||||
tds.push(
|
||||
<td
|
||||
key={cell.id}
|
||||
rowSpan={rSpan > 1 ? rSpan : undefined}
|
||||
colSpan={cSpan > 1 ? cSpan : undefined}
|
||||
className="relative cursor-pointer select-none"
|
||||
style={{
|
||||
width: w,
|
||||
height: h,
|
||||
minWidth: w,
|
||||
minHeight: h,
|
||||
backgroundColor: cellBg,
|
||||
border: `${borderW}px solid ${isSelected ? "#3b82f6" : "#e5e7eb"}`,
|
||||
padding: "2px 4px",
|
||||
fontSize: cell.fontSize ?? 12,
|
||||
fontWeight: cell.fontWeight === "bold" ? 700 : 400,
|
||||
color: cell.textColor || "#111827",
|
||||
textAlign: cell.align || "center",
|
||||
verticalAlign: cell.verticalAlign || "middle",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textOverflow: "ellipsis",
|
||||
outline: isSelected ? "2px solid #3b82f6" : "none",
|
||||
outlineOffset: "-2px",
|
||||
}}
|
||||
onMouseDown={(e) => handleCellMouseDown(r, c, e)}
|
||||
onMouseEnter={() => handleCellMouseEnter(r, c)}
|
||||
onDoubleClick={() => handleCellDoubleClick(cell)}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={editInputRef}
|
||||
className="h-full w-full border-none bg-transparent text-center text-xs outline-none"
|
||||
defaultValue={cell.cellType === "field" ? cell.field ?? "" : cell.value ?? ""}
|
||||
onBlur={(e) => handleEditBlur(cell, e.target.value)}
|
||||
onKeyDown={(e) => handleEditKeyDown(e, cell, (e.target as HTMLInputElement).value)}
|
||||
style={{ fontSize: cell.fontSize ?? 12 }}
|
||||
/>
|
||||
) : (
|
||||
<span className="pointer-events-none block truncate text-xs">
|
||||
{displayValue || <span className="text-gray-300">—</span>}
|
||||
</span>
|
||||
)}
|
||||
{cell.cellType === "field" && !isEditing && (
|
||||
<span className="absolute right-0.5 top-0.5 h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
)}
|
||||
</td>,
|
||||
);
|
||||
}
|
||||
|
||||
return tds;
|
||||
};
|
||||
|
||||
// ─── 메인 렌더링 ──────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-border bg-white shadow-sm"
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* 컨트롤 바 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
|
||||
<span className="text-sm font-bold text-gray-800">격자 양식</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 행 조절 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={handleRemoveRow}
|
||||
disabled={rowCount <= MIN_ROWS}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="flex w-12 items-center justify-center gap-0.5 text-xs font-medium text-gray-700">
|
||||
<Rows3 className="h-3 w-3 text-gray-400" />{rowCount}행
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={handleAddRow}
|
||||
disabled={rowCount >= MAX_ROWS}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<div className="mx-1.5 h-4 w-px bg-gray-300" />
|
||||
|
||||
{/* 열 조절 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={handleRemoveCol}
|
||||
disabled={colCount <= MIN_COLS}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="flex w-12 items-center justify-center gap-0.5 text-xs font-medium text-gray-700">
|
||||
<Columns3 className="h-3 w-3 text-gray-400" />{colCount}열
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={handleAddCol}
|
||||
disabled={colCount >= MAX_COLS}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<div className="mx-1.5 h-4 w-px bg-gray-300" />
|
||||
|
||||
{/* 병합 / 해제 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 border-gray-300 px-2 text-xs text-blue-600 hover:bg-blue-50 disabled:opacity-30 disabled:text-gray-400"
|
||||
onClick={handleMerge}
|
||||
disabled={!canMerge}
|
||||
>
|
||||
<Merge className="h-3 w-3" />
|
||||
병합
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 border-gray-300 px-2 text-xs text-orange-600 hover:bg-orange-50 disabled:opacity-30 disabled:text-gray-400"
|
||||
onClick={handleUnmerge}
|
||||
disabled={!canUnmerge}
|
||||
>
|
||||
<SplitSquareHorizontal className="h-3 w-3" />
|
||||
해제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 그리드 캔버스 */}
|
||||
<div className="overflow-auto p-3" style={{ maxHeight: 360 }}>
|
||||
<table
|
||||
className="select-none border-collapse"
|
||||
style={{ width: totalWidth + 28, tableLayout: "fixed" }}
|
||||
>
|
||||
<colgroup>
|
||||
<col style={{ width: 28, minWidth: 28 }} />
|
||||
{colWidths.map((w, i) => (
|
||||
<col key={i} style={{ width: w, minWidth: 40 }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border border-gray-200 bg-gray-100" />
|
||||
{colWidths.map((_, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="group/colhdr relative border border-gray-200 bg-gray-100 px-1 py-1 text-[10px] font-medium text-gray-400"
|
||||
>
|
||||
<span>{i + 1}</span>
|
||||
{colCount > MIN_COLS && (
|
||||
<button
|
||||
onClick={() => handleRemoveColAt(i)}
|
||||
className="absolute -top-0.5 right-0.5 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-red-400 text-white shadow-sm transition-colors hover:bg-red-500 group-hover/colhdr:flex"
|
||||
title={`${i + 1}열 삭제`}
|
||||
>
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</button>
|
||||
)}
|
||||
{i < colCount - 1 && (
|
||||
<div
|
||||
className={`absolute -right-[3px] top-0 z-10 h-full w-[5px] cursor-col-resize transition-colors ${
|
||||
resizingColIdx === i ? "bg-blue-400" : "hover:bg-blue-300"
|
||||
}`}
|
||||
onMouseDown={(e) => handleColResizeStart(i, e)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rowCount }).map((_, r) => {
|
||||
const gridRow = renderGridRow(r);
|
||||
return (
|
||||
<tr key={`row-${r}`}>
|
||||
<td
|
||||
className="group/rowhdr relative border border-gray-200 bg-gray-100 text-center text-[10px] font-medium text-gray-400"
|
||||
style={{ width: 28, minWidth: 28, height: rowHeights[r] ?? DEFAULT_ROW_HEIGHT, minHeight: rowHeights[r] ?? DEFAULT_ROW_HEIGHT }}
|
||||
>
|
||||
<span>{r + 1}</span>
|
||||
{rowCount > MIN_ROWS && (
|
||||
<button
|
||||
onClick={() => handleRemoveRowAt(r)}
|
||||
className="absolute -left-0.5 top-0.5 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-red-400 text-white shadow-sm transition-colors hover:bg-red-500 group-hover/rowhdr:flex"
|
||||
title={`${r + 1}행 삭제`}
|
||||
>
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</button>
|
||||
)}
|
||||
{r < rowCount - 1 && (
|
||||
<div
|
||||
className={`absolute -bottom-[3px] left-0 z-10 h-[5px] w-full cursor-row-resize transition-colors ${
|
||||
resizingRowIdx === r ? "bg-blue-400" : "hover:bg-blue-300"
|
||||
}`}
|
||||
onMouseDown={(e) => handleRowResizeStart(r, e)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
{gridRow}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 셀 설정 패널 */}
|
||||
<div className="border-t border-gray-200 bg-gray-50/50 px-4 py-3">
|
||||
{!selectedCell ? (
|
||||
<div className="flex items-center justify-center py-3 text-xs text-gray-400">
|
||||
셀을 클릭하여 선택하세요
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* 셀 위치 */}
|
||||
<span className="inline-block rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-500">
|
||||
{selectedCell.row + 1}행 {selectedCell.col + 1}열
|
||||
{((selectedCell.rowSpan ?? 1) > 1 || (selectedCell.colSpan ?? 1) > 1)
|
||||
? ` (${selectedCell.rowSpan ?? 1}x${selectedCell.colSpan ?? 1})`
|
||||
: ""}
|
||||
</span>
|
||||
|
||||
{/* 셀 타입 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">셀 타입</label>
|
||||
<Select
|
||||
value={selectedCell.cellType}
|
||||
onValueChange={(v) => updateCellProps(selectedCell.row, selectedCell.col, { cellType: v as GridCell["cellType"] })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">
|
||||
<span className="flex items-center gap-1"><Type className="h-3 w-3" /> 고정 텍스트</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="field">
|
||||
<span className="flex items-center gap-1"><Database className="h-3 w-3" /> 데이터 연결</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 셀 값 */}
|
||||
{selectedCell.cellType === "static" && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">텍스트</label>
|
||||
<Input
|
||||
className="h-8 w-full text-xs"
|
||||
value={selectedCell.value ?? ""}
|
||||
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { value: e.target.value })}
|
||||
placeholder="텍스트 입력"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedCell.cellType === "field" && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">연결 필드</label>
|
||||
{selectedCell.field ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-blue-50 px-2 py-1.5 text-xs font-medium text-blue-700">
|
||||
<Database className="h-3 w-3" />{selectedCell.field}
|
||||
</span>
|
||||
) : (
|
||||
<p className="text-[10px] text-gray-400">데이터 연결 탭에서 배치</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="h-px bg-gray-200" />
|
||||
|
||||
{/* 스타일 설정 — 1행 5열 */}
|
||||
<div className="flex items-end gap-2">
|
||||
{/* 정렬 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">정렬</label>
|
||||
<div className="flex items-center gap-0.5 rounded border border-gray-200 bg-white p-0.5">
|
||||
{(["left", "center", "right"] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded ${
|
||||
selectedCell.align === a ? "bg-blue-100 text-blue-700" : "text-gray-400 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => updateCellProps(selectedCell.row, selectedCell.col, { align: a })}
|
||||
>
|
||||
{a === "left" ? <AlignLeft className="h-3 w-3" /> : a === "center" ? <AlignCenter className="h-3 w-3" /> : <AlignRight className="h-3 w-3" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 굵기 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">굵기</label>
|
||||
<button
|
||||
className={`flex h-7 w-7 items-center justify-center rounded border ${
|
||||
selectedCell.fontWeight === "bold" ? "border-blue-300 bg-blue-100 text-blue-700" : "border-gray-200 text-gray-400 hover:bg-gray-100"
|
||||
}`}
|
||||
onClick={() => updateCellProps(selectedCell.row, selectedCell.col, { fontWeight: selectedCell.fontWeight === "bold" ? "normal" : "bold" })}
|
||||
>
|
||||
<Bold className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 글자 크기 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">크기</label>
|
||||
<Input
|
||||
type="number"
|
||||
className="h-7 w-12 text-center text-xs"
|
||||
value={selectedCell.fontSize ?? 12}
|
||||
min={8}
|
||||
max={36}
|
||||
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { fontSize: parseInt(e.target.value) || 12 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 배경색 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">배경색</label>
|
||||
<div className="flex h-7 items-center gap-1 rounded border border-gray-200 bg-white px-1.5">
|
||||
<Paintbrush className="h-3 w-3 shrink-0 text-gray-400" />
|
||||
<input
|
||||
type="color"
|
||||
className="h-5 w-6 cursor-pointer rounded border-none"
|
||||
value={selectedCell.backgroundColor || "#ffffff"}
|
||||
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { backgroundColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 글자색 */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-medium text-gray-500">글자색</label>
|
||||
<div className="flex h-7 items-center gap-1 rounded border border-gray-200 bg-white px-1.5">
|
||||
<Type className="h-3 w-3 shrink-0 text-gray-400" />
|
||||
<input
|
||||
type="color"
|
||||
className="h-5 w-6 cursor-pointer rounded border-none"
|
||||
value={selectedCell.textColor || "#111827"}
|
||||
onChange={(e) => updateCellProps(selectedCell.row, selectedCell.col, { textColor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ImageLayoutTabs.tsx — 이미지 컴포넌트 설정 탭
|
||||
*
|
||||
* [역할]
|
||||
* - 모달 내 이미지 핵심 설정: 업로드 / 자르기(Crop) / 맞춤 방식 / 캡션
|
||||
* - 시각 스타일(투명도·모서리·회전 등)은 우측 패널에서 관리
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useMemo, useCallback } from "react";
|
||||
import ReactCrop, { type Crop, type PixelCrop } from "react-image-crop";
|
||||
import "react-image-crop/dist/ReactCrop.css";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Upload,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Link,
|
||||
Crop as CropIcon,
|
||||
RotateCcw,
|
||||
AlignCenter,
|
||||
} from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Section as SharedSection, TabContent, Field, FieldGroup, Grid, InfoBox } from "./shared";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface ImageLayoutTabsProps {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
const OBJECT_FIT_OPTIONS = [
|
||||
{ value: "contain", label: "비율 유지 (포함)", desc: "전체가 보이도록 축소" },
|
||||
{ value: "cover", label: "영역 채우기", desc: "꽉 채우고 넘침 잘림" },
|
||||
{ value: "fill", label: "늘리기", desc: "비율 무시 영역 맞춤" },
|
||||
{ value: "none", label: "원본 크기", desc: "원본 그대로 표시" },
|
||||
] as const;
|
||||
|
||||
export function ImageLayoutTabs({ component }: ImageLayoutTabsProps) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Partial<ComponentConfig>) => {
|
||||
updateComponent(component.id, updates);
|
||||
},
|
||||
[component.id, updateComponent],
|
||||
);
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast({ title: "오류", description: "이미지 파일만 업로드 가능합니다.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast({ title: "오류", description: "파일 크기는 10MB 이하여야 합니다.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploadingImage(true);
|
||||
const result = await reportApi.uploadImage(file);
|
||||
if (result.success) {
|
||||
update({
|
||||
imageUrl: result.data.fileUrl,
|
||||
imageCropX: undefined,
|
||||
imageCropY: undefined,
|
||||
imageCropWidth: undefined,
|
||||
imageCropHeight: undefined,
|
||||
});
|
||||
toast({ title: "성공", description: "이미지가 업로드되었습니다." });
|
||||
}
|
||||
} catch {
|
||||
toast({ title: "오류", description: "이미지 업로드 중 오류가 발생했습니다.", variant: "destructive" });
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
update({ imageUrl: "", imageCropX: undefined, imageCropY: undefined, imageCropWidth: undefined, imageCropHeight: undefined });
|
||||
toast({ title: "알림", description: "이미지가 제거되었습니다." });
|
||||
};
|
||||
|
||||
const previewUrl = useMemo(() => {
|
||||
if (!component.imageUrl) return null;
|
||||
return getFullImageUrl(component.imageUrl);
|
||||
}, [component.imageUrl]);
|
||||
|
||||
return (
|
||||
<TabContent>
|
||||
{/* 이미지 업로드 */}
|
||||
<SharedSection emphasis icon={<Upload className="h-3.5 w-3.5" />} title="이미지 소스">
|
||||
<FieldGroup>
|
||||
<Field label="이미지 파일" help="JPG, PNG, GIF, WEBP 파일을 업로드하세요 (최대 10MB)">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-[120px] w-[140px] shrink-0 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-blue-200 bg-white">
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="미리보기" className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-1 text-gray-400">
|
||||
<ImageIcon className="h-8 w-8" />
|
||||
<span className="text-xs">미리보기</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col justify-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingImage}
|
||||
className="h-9 w-full text-xs"
|
||||
>
|
||||
{uploadingImage ? (
|
||||
<><Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />업로드 중...</>
|
||||
) : (
|
||||
<><Upload className="mr-1.5 h-3.5 w-3.5" />{component.imageUrl ? "이미지 변경" : "이미지 업로드"}</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRemoveImage}
|
||||
disabled={!component.imageUrl}
|
||||
className="h-9 w-full text-xs text-red-500 hover:bg-red-50 hover:text-red-600 disabled:text-gray-300"
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-3.5 w-3.5" />이미지 제거
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Field>
|
||||
{component.imageUrl && (
|
||||
<InfoBox variant="blue">
|
||||
<p className="truncate">
|
||||
<Link className="mr-1 inline h-3 w-3" />
|
||||
{component.imageUrl}
|
||||
</p>
|
||||
</InfoBox>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</SharedSection>
|
||||
|
||||
{/* 이미지 자르기 */}
|
||||
{previewUrl && (
|
||||
<SharedSection icon={<CropIcon className="h-3.5 w-3.5" />} title="이미지 자르기 (크롭)">
|
||||
<ImageCropEditor
|
||||
imageUrl={previewUrl}
|
||||
component={component}
|
||||
onUpdate={update}
|
||||
/>
|
||||
</SharedSection>
|
||||
)}
|
||||
|
||||
{/* 표시 방식 */}
|
||||
<SharedSection icon={<AlignCenter className="h-3.5 w-3.5" />} title="표시 방식">
|
||||
<FieldGroup>
|
||||
<Field label="이미지 맞춤 방식">
|
||||
<Select
|
||||
value={component.objectFit || "contain"}
|
||||
onValueChange={(v) => update({ objectFit: v as ComponentConfig["objectFit"] })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OBJECT_FIT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<span>{opt.label}</span>
|
||||
<span className="ml-1.5 text-[11px] text-gray-400">{opt.desc}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="대체 텍스트 (alt)">
|
||||
<Input
|
||||
value={component.imageAlt || ""}
|
||||
onChange={(e) => update({ imageAlt: e.target.value })}
|
||||
placeholder="이미지 설명 (접근성)"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</SharedSection>
|
||||
|
||||
{/* 캡션 */}
|
||||
<SharedSection icon={<ImageIcon className="h-3.5 w-3.5" />} title="캡션">
|
||||
<FieldGroup>
|
||||
<Field label="캡션 텍스트" help="이미지 아래에 설명 텍스트를 표시합니다">
|
||||
<Input
|
||||
value={component.imageCaption || ""}
|
||||
onChange={(e) => update({ imageCaption: e.target.value })}
|
||||
placeholder="이미지에 표시할 설명 텍스트"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
{component.imageCaption && (
|
||||
<Grid cols={2}>
|
||||
<Field label="위치">
|
||||
<Select
|
||||
value={component.imageCaptionPosition || "bottom"}
|
||||
onValueChange={(v) => update({ imageCaptionPosition: v as "top" | "bottom" })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="top">상단</SelectItem>
|
||||
<SelectItem value="bottom">하단</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="정렬">
|
||||
<Select
|
||||
value={component.imageCaptionAlign || "center"}
|
||||
onValueChange={(v) => update({ imageCaptionAlign: v as "left" | "center" | "right" })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</Grid>
|
||||
)}
|
||||
</FieldGroup>
|
||||
</SharedSection>
|
||||
|
||||
<InfoBox variant="gray">
|
||||
투명도, 테두리, 모서리, 회전 등 스타일 설정은 우측 패널에서 변경할 수 있습니다.
|
||||
</InfoBox>
|
||||
</TabContent>
|
||||
);
|
||||
}
|
||||
|
||||
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
* 이미지 자르기(Crop) 에디터
|
||||
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
|
||||
|
||||
interface ImageCropEditorProps {
|
||||
imageUrl: string;
|
||||
component: ComponentConfig;
|
||||
onUpdate: (updates: Partial<ComponentConfig>) => void;
|
||||
}
|
||||
|
||||
function ImageCropEditor({ imageUrl, component, onUpdate }: ImageCropEditorProps) {
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const hasCrop = component.imageCropWidth != null && component.imageCropHeight != null;
|
||||
|
||||
const [crop, setCrop] = useState<Crop | undefined>(() => {
|
||||
if (hasCrop) {
|
||||
return {
|
||||
unit: "%" as const,
|
||||
x: component.imageCropX ?? 0,
|
||||
y: component.imageCropY ?? 0,
|
||||
width: component.imageCropWidth ?? 100,
|
||||
height: component.imageCropHeight ?? 100,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const handleCropComplete = useCallback(
|
||||
(_pixelCrop: PixelCrop, percentCrop: Crop) => {
|
||||
if (percentCrop.width < 1 || percentCrop.height < 1) return;
|
||||
onUpdate({
|
||||
imageCropX: Math.round(percentCrop.x * 100) / 100,
|
||||
imageCropY: Math.round(percentCrop.y * 100) / 100,
|
||||
imageCropWidth: Math.round(percentCrop.width * 100) / 100,
|
||||
imageCropHeight: Math.round(percentCrop.height * 100) / 100,
|
||||
});
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleResetCrop = () => {
|
||||
setCrop(undefined);
|
||||
onUpdate({
|
||||
imageCropX: undefined,
|
||||
imageCropY: undefined,
|
||||
imageCropWidth: undefined,
|
||||
imageCropHeight: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-lg border border-gray-200 bg-gray-50">
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(_, percentCrop) => setCrop(percentCrop)}
|
||||
onComplete={handleCropComplete}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imageUrl}
|
||||
alt="자르기 편집"
|
||||
style={{ maxWidth: "100%", maxHeight: 300, display: "block" }}
|
||||
/>
|
||||
</ReactCrop>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] text-gray-400">
|
||||
{hasCrop
|
||||
? `자르기 영역: ${Math.round(component.imageCropWidth ?? 0)}% x ${Math.round(component.imageCropHeight ?? 0)}%`
|
||||
: "드래그하여 자르기 영역을 지정하세요"}
|
||||
</p>
|
||||
{hasCrop && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleResetCrop}
|
||||
className="h-7 text-xs text-gray-500"
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3 w-3" />초기화
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function getShadowStyle(shadow: string): string {
|
||||
switch (shadow) {
|
||||
case "sm": return "0 1px 2px rgba(0,0,0,0.05)";
|
||||
case "md": return "0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)";
|
||||
case "lg": return "0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)";
|
||||
default: return "none";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* QuerySettingsTab.tsx
|
||||
*
|
||||
* 데이터 연결 탭 — 기존 QueryManager를 모달 탭 안에서 렌더링한다.
|
||||
*
|
||||
* [사용처]
|
||||
* - ComponentSettingsModal의 "데이터 연결" 탭
|
||||
* - QueryManager는 Context에서 직접 queries/setQueries를 구독하므로
|
||||
* 별도 props 없이 그대로 mount하면 동작한다.
|
||||
*
|
||||
* [설계 결정]
|
||||
* - 외부 DB 연결 목록은 QueryManager 내부 useEffect로 로드되므로
|
||||
* 탭을 열 때마다 재조회된다. 빈번한 재마운트를 최소화하려면
|
||||
* Context로 올리는 방향을 검토할 수 있으나, 현재는 단순성 우선.
|
||||
*/
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { QueryManager } from "../QueryManager";
|
||||
|
||||
export function QuerySettingsTab() {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2">
|
||||
<QueryManager />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SettingsModalShell.tsx — 컴포넌트 설정 모달 공통 Shell
|
||||
*
|
||||
* [역할]
|
||||
* 모든 컴포넌트 설정 모달이 동일한 형식을 유지하도록 하는 재사용 모듈.
|
||||
* 파란색 그라데이션 헤더 + 탭(헤더 하단 인라인) + 스크롤 콘텐츠 + 하단 Footer 구조.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Save, AlertTriangle, CheckCircle } from "lucide-react";
|
||||
|
||||
const DND_ROOT_ID = "report-designer-dnd-root";
|
||||
|
||||
export interface ModalTabDef {
|
||||
key: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ModalAlert {
|
||||
type: "success" | "warning";
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface SettingsModalShellProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
tabs: ModalTabDef[];
|
||||
activeTab: string;
|
||||
onTabChange: (key: string) => void;
|
||||
onSave: () => void;
|
||||
onClose: () => void;
|
||||
alert?: ModalAlert | null;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: string;
|
||||
saveLabel?: string;
|
||||
}
|
||||
|
||||
export function SettingsModalShell({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
icon,
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onSave,
|
||||
onClose,
|
||||
alert,
|
||||
children,
|
||||
maxWidth = "max-w-4xl",
|
||||
saveLabel = "저장",
|
||||
}: SettingsModalShellProps) {
|
||||
const [dndContainer, setDndContainer] = useState<HTMLElement | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const el = document.getElementById(DND_ROOT_ID);
|
||||
setDndContainer(el ?? undefined);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
container={dndContainer}
|
||||
className={`flex h-[92vh] ${maxWidth} flex-col gap-0 overflow-hidden rounded-xl p-0 [&>button]:hidden`}
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{title} 설정 모달</DialogDescription>
|
||||
|
||||
{/* Gradient Header */}
|
||||
<div className="shrink-0 rounded-t-xl bg-linear-to-r from-blue-600 to-indigo-600">
|
||||
{/* Title row */}
|
||||
<div className="flex items-center gap-3 px-6 pt-4 pb-3">
|
||||
{icon && <span className="shrink-0 text-white/90">{icon}</span>}
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
className="ml-auto shrink-0 rounded-md p-1.5 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">닫기</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs row */}
|
||||
{tabs.length > 0 && (
|
||||
<div className="flex items-end gap-1 px-6 pb-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
className={`flex items-center gap-1.5 rounded-t-lg px-4 py-2 text-xs font-medium transition-all ${
|
||||
activeTab === tab.key
|
||||
? "bg-white text-blue-700 shadow-sm"
|
||||
: "text-white/80 hover:bg-white/15 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{tab.icon && <span className="shrink-0">{tab.icon}</span>}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 토스트 알림 */}
|
||||
{alert && (
|
||||
<div className="pointer-events-none absolute right-4 top-2 z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div
|
||||
className={`pointer-events-auto flex items-start gap-3 rounded-lg border bg-white px-4 py-3 shadow-lg ${
|
||||
alert.type === "success" ? "border-gray-200" : "border-amber-200"
|
||||
}`}
|
||||
>
|
||||
{alert.type === "success" ? (
|
||||
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-500">
|
||||
<CheckCircle className="h-3.5 w-3.5 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-amber-500">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-semibold text-gray-900">
|
||||
{alert.type === "success" ? "성공" : "알림"}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">{alert.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto bg-gray-50">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="shrink-0 rounded-b-xl border-t bg-white px-6 py-3 flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onSave}
|
||||
className="h-8 px-4 text-xs bg-blue-600 hover:bg-blue-700 text-white border-blue-600"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saveLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 px-4 text-xs"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 토스트 알림 훅 ──────────────────────────────────────────────────────────
|
||||
|
||||
export function useModalAlert() {
|
||||
const [alert, setAlert] = useState<ModalAlert | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showAlert = useCallback((type: ModalAlert["type"], message: string) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setAlert({ type, message });
|
||||
timerRef.current = setTimeout(() => setAlert(null), 3000);
|
||||
}, []);
|
||||
|
||||
const clearAlert = useCallback(() => setAlert(null), []);
|
||||
|
||||
return { alert, showAlert, clearAlert };
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TableCanvasEditor — 테이블 레이아웃 탭의 시각적 편집기
|
||||
*
|
||||
* - 컨트롤 바(열 추가, 행 조절)는 고정
|
||||
* - 테이블 미리보기만 가로/세로 스크롤
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Trash2, Minus, Rows3, Columns3 } from "lucide-react";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
type TableColumn = NonNullable<ComponentConfig["tableColumns"]>[number];
|
||||
|
||||
const MIN_COL_WIDTH = 60;
|
||||
const DEFAULT_COL_WIDTH = 120;
|
||||
const MIN_ROWS = 1;
|
||||
const MAX_ROWS = 50;
|
||||
const MAX_COLUMNS = 14;
|
||||
const DEFAULT_ROWS = 3;
|
||||
|
||||
interface TableCanvasEditorProps {
|
||||
columns: TableColumn[];
|
||||
onColumnsChange: (columns: TableColumn[]) => void;
|
||||
rowCount?: number;
|
||||
onRowCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export function TableCanvasEditor({ columns, onColumnsChange, rowCount, onRowCountChange }: TableCanvasEditorProps) {
|
||||
const displayRows = rowCount ?? DEFAULT_ROWS;
|
||||
const [resizingIdx, setResizingIdx] = useState<number | null>(null);
|
||||
const startXRef = useRef(0);
|
||||
const startWidthRef = useRef(0);
|
||||
|
||||
const canAddColumn = columns.length < MAX_COLUMNS;
|
||||
|
||||
const handleAddColumn = useCallback(() => {
|
||||
if (!canAddColumn) return;
|
||||
onColumnsChange([
|
||||
...columns,
|
||||
{
|
||||
field: "",
|
||||
header: `열 ${columns.length + 1}`,
|
||||
width: DEFAULT_COL_WIDTH,
|
||||
align: "left",
|
||||
mappingType: "field",
|
||||
summaryType: "NONE",
|
||||
visible: true,
|
||||
numberFormat: "none",
|
||||
},
|
||||
]);
|
||||
}, [columns, onColumnsChange, canAddColumn]);
|
||||
|
||||
const handleRemoveColumn = useCallback(
|
||||
(idx: number) => {
|
||||
if (columns.length <= 1) return;
|
||||
const remaining = columns.filter((_, i) => i !== idx);
|
||||
const renumbered = remaining.map((col, i) => {
|
||||
const isDefaultHeader = /^열 \d+$/.test(col.header);
|
||||
return isDefaultHeader ? { ...col, header: `열 ${i + 1}` } : col;
|
||||
});
|
||||
onColumnsChange(renumbered);
|
||||
},
|
||||
[columns, onColumnsChange],
|
||||
);
|
||||
|
||||
const handleRemoveLastColumn = useCallback(() => {
|
||||
if (columns.length <= 1) return;
|
||||
handleRemoveColumn(columns.length - 1);
|
||||
}, [columns, handleRemoveColumn]);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(idx: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setResizingIdx(idx);
|
||||
startXRef.current = e.clientX;
|
||||
startWidthRef.current = columns[idx]?.width || DEFAULT_COL_WIDTH;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const delta = moveEvent.clientX - startXRef.current;
|
||||
const newWidth = Math.max(MIN_COL_WIDTH, startWidthRef.current + delta);
|
||||
onColumnsChange(
|
||||
columns.map((col, i) => (i === idx ? { ...col, width: newWidth } : col)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizingIdx(null);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[columns, onColumnsChange],
|
||||
);
|
||||
|
||||
const totalWidth = columns.reduce((sum, col) => sum + (col.width || DEFAULT_COL_WIDTH), 0);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-white shadow-sm">
|
||||
{/* 컨트롤 바 */}
|
||||
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2.5">
|
||||
<span className="text-sm font-bold text-gray-800">테이블 레이아웃</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={() => onRowCountChange?.(Math.max(MIN_ROWS, displayRows - 1))}
|
||||
disabled={displayRows <= MIN_ROWS}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="flex w-12 items-center justify-center gap-0.5 text-xs font-medium text-gray-700">
|
||||
<Rows3 className="h-3 w-3 text-gray-400" />{displayRows}행
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={() => onRowCountChange?.(Math.min(MAX_ROWS, displayRows + 1))}
|
||||
disabled={displayRows >= MAX_ROWS}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
<div className="mx-1.5 h-4 w-px bg-gray-300" />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={handleRemoveLastColumn}
|
||||
disabled={columns.length <= 1}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="flex w-12 items-center justify-center gap-0.5 text-xs font-medium text-gray-700">
|
||||
<Columns3 className="h-3 w-3 text-gray-400" />{columns.length}열
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 border-blue-300 p-0 text-blue-600 hover:bg-blue-50 disabled:opacity-30"
|
||||
onClick={handleAddColumn}
|
||||
disabled={!canAddColumn}
|
||||
title={canAddColumn ? "열 추가" : `최대 ${MAX_COLUMNS}개까지 추가 가능합니다`}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 미리보기 */}
|
||||
{columns.length > 0 ? (
|
||||
<div className="overflow-auto p-3" style={{ maxHeight: 360 }}>
|
||||
<table
|
||||
className="select-none border-collapse"
|
||||
style={{ width: totalWidth + 28, tableLayout: "fixed" }}
|
||||
>
|
||||
<colgroup>
|
||||
<col style={{ width: 28, minWidth: 28 }} />
|
||||
{columns.map((col, idx) => (
|
||||
<col key={idx} style={{ width: col.width || DEFAULT_COL_WIDTH, minWidth: MIN_COL_WIDTH }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
{/* 열 번호 헤더 */}
|
||||
<tr>
|
||||
<th className="border border-gray-200 bg-gray-100" />
|
||||
{columns.map((col, idx) => (
|
||||
<th
|
||||
key={idx}
|
||||
className="group/colhdr relative border border-gray-200 bg-gray-100 px-1 py-1 text-[10px] font-medium text-gray-400"
|
||||
>
|
||||
<span>{idx + 1}</span>
|
||||
{columns.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleRemoveColumn(idx)}
|
||||
className="absolute -top-0.5 right-0.5 hidden h-3.5 w-3.5 items-center justify-center rounded-full bg-red-400 text-white shadow-sm transition-colors hover:bg-red-500 group-hover/colhdr:flex"
|
||||
title={`${idx + 1}열 삭제`}
|
||||
>
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</button>
|
||||
)}
|
||||
{idx < columns.length - 1 && (
|
||||
<div
|
||||
className={`absolute -right-[3px] top-0 z-10 h-full w-[5px] cursor-col-resize transition-colors ${
|
||||
resizingIdx === idx ? "bg-blue-400" : "hover:bg-blue-300"
|
||||
}`}
|
||||
onMouseDown={(e) => handleResizeStart(idx, e)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
{/* 열 이름 헤더 */}
|
||||
<tr>
|
||||
<td className="border border-gray-200 bg-gray-100" />
|
||||
{columns.map((col, idx) => (
|
||||
<th
|
||||
key={idx}
|
||||
className="border border-gray-200 bg-gray-50 px-1 text-center text-xs font-semibold text-gray-700"
|
||||
style={{ height: 32, minHeight: 32 }}
|
||||
>
|
||||
<span className="block truncate">{col.header || `열 ${idx + 1}`}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: displayRows }).map((_, rowIdx) => (
|
||||
<tr key={rowIdx}>
|
||||
<td
|
||||
className="border border-gray-200 bg-gray-100 text-center text-[10px] font-medium text-gray-400"
|
||||
style={{ width: 28, minWidth: 28, height: 32, minHeight: 32 }}
|
||||
>
|
||||
{rowIdx + 1}
|
||||
</td>
|
||||
{columns.map((col, colIdx) => (
|
||||
<td
|
||||
key={colIdx}
|
||||
className="select-none border border-gray-200"
|
||||
style={{
|
||||
height: 32,
|
||||
minHeight: 32,
|
||||
padding: "2px 4px",
|
||||
textAlign: col.align || "center",
|
||||
verticalAlign: "middle",
|
||||
fontSize: 12,
|
||||
color: "#9ca3af",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap" as const,
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
<span className="pointer-events-none block truncate text-xs text-gray-300">—</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8 text-xs text-gray-400">
|
||||
셀을 클릭하여 선택하세요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TableColumnDropZone — 테이블 데이터 연결 탭의 드롭 영역
|
||||
*
|
||||
* 탭1에서 설정한 열 개수만큼 슬롯을 표시하고,
|
||||
* TableColumnPalette에서 드래그한 컬럼을 드롭하여 배치.
|
||||
* 이미 배치된 컬럼은 다른 슬롯으로 드래그하여 위치 교환 가능.
|
||||
* 배치된 컬럼에는 인라인으로 헤더명/숫자형식 설정 가능.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useDrag, useDrop } from "react-dnd";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { X, Columns, GripVertical } from "lucide-react";
|
||||
import { TABLE_COLUMN_DND_TYPE } from "./TableColumnPalette";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
type TableColumn = NonNullable<ComponentConfig["tableColumns"]>[number];
|
||||
|
||||
export const TABLE_SLOT_DND_TYPE = "table-slot";
|
||||
|
||||
interface TableColumnDropZoneProps {
|
||||
columns: TableColumn[];
|
||||
onUpdate: (idx: number, updates: Partial<TableColumn>) => void;
|
||||
onDrop: (slotIndex: number, columnName: string, dataType: string) => void;
|
||||
onClear: (slotIndex: number) => void;
|
||||
onMove: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
interface SlotDragItem {
|
||||
type: typeof TABLE_SLOT_DND_TYPE;
|
||||
sourceIndex: number;
|
||||
}
|
||||
|
||||
interface PaletteDragItem {
|
||||
columnName: string;
|
||||
dataType: string;
|
||||
}
|
||||
|
||||
interface SlotProps {
|
||||
col: TableColumn;
|
||||
idx: number;
|
||||
onUpdate: (idx: number, updates: Partial<TableColumn>) => void;
|
||||
onDrop: (slotIndex: number, columnName: string, dataType: string) => void;
|
||||
onClear: (slotIndex: number) => void;
|
||||
onMove: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
function DropSlot({ col, idx, onUpdate, onDrop, onClear, onMove }: SlotProps) {
|
||||
const isEmpty = !col.field;
|
||||
|
||||
const [{ isDragging }, drag, preview] = useDrag(() => ({
|
||||
type: TABLE_SLOT_DND_TYPE,
|
||||
item: { type: TABLE_SLOT_DND_TYPE, sourceIndex: idx } as SlotDragItem,
|
||||
canDrag: !isEmpty,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
}), [idx, isEmpty]);
|
||||
|
||||
const [{ isOver, canDrop: canDropHere }, drop] = useDrop(() => ({
|
||||
accept: [TABLE_COLUMN_DND_TYPE, TABLE_SLOT_DND_TYPE],
|
||||
drop: (item: PaletteDragItem | SlotDragItem, monitor) => {
|
||||
const itemType = monitor.getItemType();
|
||||
if (itemType === TABLE_SLOT_DND_TYPE) {
|
||||
const slotItem = item as SlotDragItem;
|
||||
if (slotItem.sourceIndex !== idx) {
|
||||
onMove(slotItem.sourceIndex, idx);
|
||||
}
|
||||
} else {
|
||||
const paletteItem = item as PaletteDragItem;
|
||||
onDrop(idx, paletteItem.columnName, paletteItem.dataType);
|
||||
}
|
||||
},
|
||||
canDrop: (item, monitor) => {
|
||||
if (monitor.getItemType() === TABLE_SLOT_DND_TYPE) {
|
||||
return (item as SlotDragItem).sourceIndex !== idx;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
}), [idx, onDrop, onMove]);
|
||||
|
||||
const isActive = isOver && canDropHere;
|
||||
|
||||
const ref = (node: HTMLDivElement | null) => {
|
||||
preview(drop(node));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative rounded-lg border p-3 transition-all ${
|
||||
isDragging
|
||||
? "border-blue-300 bg-blue-50/60 opacity-50 shadow-inner"
|
||||
: isActive
|
||||
? "border-blue-400 bg-blue-50 shadow-sm ring-2 ring-blue-200"
|
||||
: isEmpty
|
||||
? "border-dashed border-gray-300 bg-gray-50/50"
|
||||
: "border-gray-200 bg-white hover:border-blue-200 hover:bg-blue-50/20"
|
||||
}`}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center py-4 text-center">
|
||||
<Columns className="mb-1 h-5 w-5 text-gray-300" />
|
||||
<span className="text-xs text-gray-400">
|
||||
{isActive ? "여기에 놓으세요" : "컬럼을 드래그하여 배치"}
|
||||
</span>
|
||||
<span className="mt-0.5 text-[10px] text-gray-300">열 {idx + 1}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{/* 드래그 핸들 + 컬럼명 배지 + 제거 버튼 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
ref={drag as any}
|
||||
className="flex h-5 w-5 cursor-grab items-center justify-center rounded text-gray-400 hover:bg-gray-100 hover:text-gray-600 active:cursor-grabbing"
|
||||
title="드래그하여 위치 이동"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded bg-blue-100 px-2 py-0.5 font-mono text-xs font-medium text-blue-700">
|
||||
<Columns className="h-3 w-3" />
|
||||
{col.field}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onClear(idx)}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-400 hover:bg-red-50 hover:text-red-500"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 헤더명 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] text-gray-500">헤더명</label>
|
||||
<Input
|
||||
value={col.header}
|
||||
onChange={(e) => onUpdate(idx, { header: e.target.value })}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 숫자형식 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-[10px] text-gray-500">숫자형식</label>
|
||||
<Select
|
||||
value={col.numberFormat || "none"}
|
||||
onValueChange={(v) => onUpdate(idx, { numberFormat: v as "none" | "comma" | "currency" })}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="comma">천단위(,)</SelectItem>
|
||||
<SelectItem value="currency">통화</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TableColumnDropZone({ columns, onUpdate, onDrop, onClear, onMove }: TableColumnDropZoneProps) {
|
||||
const filledCount = columns.filter((c) => c.field).length;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-white shadow-sm">
|
||||
<div className="border-b border-gray-200 bg-gray-50 px-4 py-2.5">
|
||||
<span className="text-sm font-bold text-gray-800">
|
||||
테이블 컬럼 배치
|
||||
</span>
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{filledCount}/{columns.length} 배치됨
|
||||
</span>
|
||||
{filledCount >= 2 && (
|
||||
<span className="ml-2 text-[10px] text-gray-400">
|
||||
(드래그로 위치 변경 가능)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{columns.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-gray-400">
|
||||
레이아웃 탭에서 열을 먼저 추가하세요.
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{ gridTemplateColumns: `repeat(${Math.min(columns.length, 4)}, 1fr)` }}
|
||||
>
|
||||
{columns.map((col, idx) => (
|
||||
<DropSlot
|
||||
key={idx}
|
||||
col={col}
|
||||
idx={idx}
|
||||
onUpdate={onUpdate}
|
||||
onDrop={onDrop}
|
||||
onClear={onClear}
|
||||
onMove={onMove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TableColumnPalette — 테이블 데이터 연결 탭의 컬럼 팔레트
|
||||
*
|
||||
* 2단계 플로우:
|
||||
* 1. 체크박스로 사용할 컬럼을 중복 선택
|
||||
* 2. 선택된 컬럼만 드래그 가능한 칩으로 표시 → 드롭 존에 배치
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { useDrag } from "react-dnd";
|
||||
import { Columns, Loader2, ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
|
||||
export const TABLE_COLUMN_DND_TYPE = "table-column";
|
||||
|
||||
export interface SchemaColumn {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}
|
||||
|
||||
// ─── 드래그 가능한 선택된 컬럼 칩 ────────────────────────────────────────────
|
||||
|
||||
interface DraggableColumnProps {
|
||||
column: SchemaColumn;
|
||||
placed?: boolean;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
function DraggableColumn({ column, placed = false, onRemove }: DraggableColumnProps) {
|
||||
const [{ isDragging }, drag] = useDrag(() => ({
|
||||
type: TABLE_COLUMN_DND_TYPE,
|
||||
item: { columnName: column.column_name, dataType: column.data_type },
|
||||
canDrag: () => !placed,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
}), [column, placed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={placed ? undefined : (drag as any)}
|
||||
className={`relative flex items-center gap-1.5 rounded-lg border px-3 py-2 transition-colors ${
|
||||
placed
|
||||
? "cursor-default border-gray-200 bg-gray-100 opacity-50"
|
||||
: `cursor-move border-blue-200 bg-blue-50 hover:border-blue-400 hover:bg-blue-100 ${isDragging ? "opacity-50 shadow-lg" : ""}`
|
||||
}`}
|
||||
>
|
||||
<Columns className={`h-3.5 w-3.5 shrink-0 ${placed ? "text-gray-400" : "text-blue-500"}`} />
|
||||
<span className={`truncate font-mono text-xs font-medium ${placed ? "text-gray-400 line-through" : "text-blue-700"}`}>
|
||||
{column.column_name}
|
||||
</span>
|
||||
{placed ? (
|
||||
<span className="shrink-0 rounded bg-green-100 px-1 py-0.5 text-[9px] text-green-600">배치됨</span>
|
||||
) : (
|
||||
<span className="shrink-0 rounded bg-blue-100 px-1 py-0.5 text-[9px] text-blue-500">
|
||||
{column.data_type}
|
||||
</span>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
className="absolute -right-1.5 -top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 text-white shadow-sm hover:bg-red-500"
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 메인 팔레트 ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface TableColumnPaletteProps {
|
||||
columns: SchemaColumn[];
|
||||
loading?: boolean;
|
||||
maxSelectable?: number;
|
||||
placedColumns?: Set<string>;
|
||||
onColumnRemove?: (columnName: string) => void;
|
||||
}
|
||||
|
||||
export function TableColumnPalette({ columns, loading, maxSelectable = 0, placedColumns, onColumnRemove }: TableColumnPaletteProps) {
|
||||
const [selectedNames, setSelectedNames] = useState<Set<string>>(new Set());
|
||||
const [listExpanded, setListExpanded] = useState(false);
|
||||
|
||||
// 이미 배치된 컬럼을 선택 목록에 자동 포함
|
||||
useEffect(() => {
|
||||
if (!placedColumns || placedColumns.size === 0) return;
|
||||
setSelectedNames((prev) => {
|
||||
const next = new Set(prev);
|
||||
let changed = false;
|
||||
placedColumns.forEach((name) => {
|
||||
if (!next.has(name)) {
|
||||
next.add(name);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [placedColumns]);
|
||||
|
||||
const isLimitReached = maxSelectable > 0 && selectedNames.size >= maxSelectable;
|
||||
|
||||
const selectedColumns = useMemo(
|
||||
() => columns.filter((c) => selectedNames.has(c.column_name)),
|
||||
[columns, selectedNames],
|
||||
);
|
||||
|
||||
const toggleColumn = (name: string) => {
|
||||
setSelectedNames((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
if (maxSelectable > 0 && next.size >= maxSelectable) return prev;
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Step 1: 컬럼 선택 */}
|
||||
<div className="rounded-xl border border-border bg-white">
|
||||
<button
|
||||
onClick={() => setListExpanded(!listExpanded)}
|
||||
className="flex w-full items-center justify-between px-4 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-bold text-gray-800">
|
||||
열 선택
|
||||
</span>
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{selectedNames.size}{maxSelectable > 0 ? `/${maxSelectable}` : `/${columns.length}`} 선택됨
|
||||
</span>
|
||||
{maxSelectable > 0 && isLimitReached && (
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-600">
|
||||
최대
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{listExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{listExpanded && (
|
||||
<div className="border-t border-gray-100">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-xs text-gray-500">컬럼 로딩 중...</span>
|
||||
</div>
|
||||
) : columns.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-gray-400">
|
||||
레이아웃 탭에서 데이터 소스를 먼저 선택하세요.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{maxSelectable > 0 && (
|
||||
<div className="border-b border-gray-100 px-4 py-2">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
배치 가능 열 수: {maxSelectable}개
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[200px] overflow-y-auto divide-y divide-gray-50">
|
||||
{columns.map((col) => {
|
||||
const checked = selectedNames.has(col.column_name);
|
||||
const disabled = !checked && isLimitReached;
|
||||
return (
|
||||
<label
|
||||
key={col.column_name}
|
||||
className={`flex items-center gap-3 px-4 py-2 transition-colors ${
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-40"
|
||||
: "cursor-pointer hover:bg-gray-50"
|
||||
} ${checked ? "bg-blue-50/40" : ""}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={() => toggleColumn(col.column_name)}
|
||||
className="h-3.5 w-3.5 shrink-0 rounded border-gray-300 text-blue-600 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span
|
||||
className={`font-mono text-xs font-medium ${
|
||||
checked ? "text-blue-700" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{col.column_name}
|
||||
</span>
|
||||
<span className="rounded bg-gray-100 px-1 py-0.5 text-[9px] text-gray-500">
|
||||
{col.data_type}
|
||||
</span>
|
||||
{col.is_nullable === "NO" && (
|
||||
<span className="rounded bg-orange-50 px-1 py-0.5 text-[9px] text-orange-500">
|
||||
NOT NULL
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 2: 선택된 컬럼 드래그 영역 */}
|
||||
{selectedColumns.length > 0 && (
|
||||
<div className="rounded-xl border border-border bg-white p-4">
|
||||
<div className="mb-3 text-sm font-bold text-gray-800">
|
||||
선택된 열
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
드래그하여 아래 표에 배치 ({selectedColumns.length}개)
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{selectedColumns.map((col) => (
|
||||
<DraggableColumn
|
||||
key={col.column_name}
|
||||
column={col}
|
||||
placed={placedColumns?.has(col.column_name)}
|
||||
onRemove={() => {
|
||||
setSelectedNames((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(col.column_name);
|
||||
return next;
|
||||
});
|
||||
onColumnRemove?.(col.column_name);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { Eye } from "lucide-react";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface TablePreviewPanelProps {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
export function TablePreviewPanel({ component }: TablePreviewPanelProps) {
|
||||
const visibleCols = (component.tableColumns ?? []).filter((c) => c.visible !== false);
|
||||
const hasColumns = visibleCols.length > 0;
|
||||
const previewRowCount = component.tableRowCount ?? 3;
|
||||
|
||||
const headerBg = component.headerBackgroundColor || "#f3f4f6";
|
||||
const headerColor = component.headerTextColor || "#111827";
|
||||
const rowHeight = component.rowHeight ?? 28;
|
||||
const hasBorder = component.showBorder !== false;
|
||||
const borderClass = hasBorder ? "border border-gray-300" : "";
|
||||
|
||||
const totalConfigWidth = visibleCols.reduce((s, c) => s + (c.width || 120), 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2 mb-1 text-sm font-semibold text-gray-700">
|
||||
<Eye className="w-4 h-4 text-blue-600" />
|
||||
미리보기 (저장 전 상태)
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
실제 데이터는 저장 후 확인 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center w-full min-h-[600px] rounded-xl bg-gray-100 border border-gray-200 p-8 overflow-auto">
|
||||
{hasColumns ? (
|
||||
<div
|
||||
className="rounded-lg bg-white shadow-sm overflow-hidden"
|
||||
style={{
|
||||
width: Math.min(component.width || 700, 700),
|
||||
minHeight: Math.min(component.height || 400, 400),
|
||||
}}
|
||||
>
|
||||
<table className="w-full border-collapse text-xs" style={{ tableLayout: "fixed" }}>
|
||||
<colgroup>
|
||||
{visibleCols.map((col, idx) => {
|
||||
const ratio = (col.width || 120) / totalConfigWidth;
|
||||
return <col key={idx} style={{ width: `${(ratio * 100).toFixed(2)}%` }} />;
|
||||
})}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: headerBg, color: headerColor }}>
|
||||
{visibleCols.map((col, idx) => (
|
||||
<th
|
||||
key={idx}
|
||||
className={borderClass}
|
||||
style={{
|
||||
padding: "4px 6px",
|
||||
textAlign: col.align || "left",
|
||||
fontWeight: 600,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: previewRowCount }).map((_, rowIdx) => (
|
||||
<tr key={rowIdx}>
|
||||
{visibleCols.map((col, colIdx) => (
|
||||
<td
|
||||
key={colIdx}
|
||||
className={borderClass}
|
||||
style={{
|
||||
padding: "4px 6px",
|
||||
textAlign: col.align || "left",
|
||||
height: `${rowHeight}px`,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: "#d1d5db",
|
||||
}}
|
||||
>
|
||||
{col.field ? `{${col.field}}` : "—"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-40 text-sm text-gray-400">
|
||||
컬럼을 설정하면 미리보기가 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TextLayoutTabs.tsx — 텍스트 컴포넌트 설정
|
||||
*
|
||||
* [역할]
|
||||
* - 텍스트 내용 입력 + 데이터 바인딩(queryId/fieldName) 설정
|
||||
* - ComponentSettingsModal 내에 직접 임베드되어 사용
|
||||
*
|
||||
* [사용처]
|
||||
* - TextProperties.tsx (section="data"일 때)
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Type, Database, Link2 } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { Section, TabContent, Field, FieldGroup, InfoBox } from "./shared";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface TextLayoutTabsProps {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
export function TextLayoutTabs({ component }: TextLayoutTabsProps) {
|
||||
const { updateComponent, queries, getQueryResult } = useReportDesigner();
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Partial<ComponentConfig>) => updateComponent(component.id, updates),
|
||||
[component.id, updateComponent],
|
||||
);
|
||||
|
||||
const selectedQueryFields = useMemo(() => {
|
||||
if (!component.queryId) return [];
|
||||
const result = getQueryResult(component.queryId);
|
||||
if (result?.fields) return result.fields;
|
||||
return [];
|
||||
}, [component.queryId, getQueryResult]);
|
||||
|
||||
const hasBinding = !!(component.queryId && component.fieldName);
|
||||
|
||||
return (
|
||||
<TabContent>
|
||||
{/* 데이터 바인딩 섹션 */}
|
||||
<Section
|
||||
emphasis
|
||||
icon={<Database className="h-3.5 w-3.5" />}
|
||||
title="데이터 바인딩"
|
||||
>
|
||||
<FieldGroup>
|
||||
<Field label="데이터 소스 (쿼리)">
|
||||
<Select
|
||||
value={component.queryId || "none"}
|
||||
onValueChange={(value) =>
|
||||
update({
|
||||
queryId: value === "none" ? undefined : value,
|
||||
fieldName: value === "none" ? undefined : component.fieldName,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="쿼리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">직접 입력 (바인딩 없음)</SelectItem>
|
||||
{queries.map((q) => (
|
||||
<SelectItem key={q.id} value={q.id}>
|
||||
{q.name} ({q.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{component.queryId && (
|
||||
<Field label="바인딩 필드">
|
||||
{selectedQueryFields.length > 0 ? (
|
||||
<Select
|
||||
value={component.fieldName || "none"}
|
||||
onValueChange={(value) =>
|
||||
update({ fieldName: value === "none" ? undefined : value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{selectedQueryFields.map((field) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={component.fieldName || ""}
|
||||
onChange={(e) => update({ fieldName: e.target.value || undefined })}
|
||||
placeholder="필드명 직접 입력 (예: doc_number)"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{!component.queryId && component.fieldName && (
|
||||
<Field label="바인딩 필드 (쿼리 미연결)">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<Input
|
||||
value={component.fieldName || ""}
|
||||
onChange={(e) => update({ fieldName: e.target.value || undefined })}
|
||||
placeholder="필드명"
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-amber-600">
|
||||
쿼리를 연결하면 실제 데이터가 표시됩니다.
|
||||
</p>
|
||||
</Field>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
{hasBinding && (
|
||||
<InfoBox variant="blue">
|
||||
쿼리 실행 시 <code className="rounded bg-blue-100 px-1 text-xs font-mono">{`{${component.fieldName}}`}</code> 값이 표시됩니다.
|
||||
</InfoBox>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* 기본값 / 정적 텍스트 */}
|
||||
<Section icon={<Type className="h-3.5 w-3.5" />} title={hasBinding ? "기본값 (데이터 없을 때)" : "텍스트 내용"}>
|
||||
<FieldGroup>
|
||||
<Field label={hasBinding ? "기본 표시 텍스트" : "표시 텍스트"}>
|
||||
<Textarea
|
||||
value={component.defaultValue || ""}
|
||||
onChange={(e) => update({ defaultValue: e.target.value })}
|
||||
placeholder={
|
||||
hasBinding
|
||||
? "데이터가 없을 때 표시할 텍스트"
|
||||
: "텍스트 내용을 입력하세요 (엔터로 줄바꿈 가능)"
|
||||
}
|
||||
className="min-h-[72px] resize-y text-sm"
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</Section>
|
||||
|
||||
<InfoBox variant="gray">
|
||||
폰트, 색상, 정렬 등 스타일 설정은 우측 패널에서 변경할 수 있습니다.
|
||||
</InfoBox>
|
||||
</TabContent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Database,
|
||||
Plus,
|
||||
Trash2,
|
||||
Link2,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Table2,
|
||||
} from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ReportQuery } from "@/contexts/report-designer/types";
|
||||
import type {
|
||||
ComponentConfig,
|
||||
VisualDataSource,
|
||||
VisualDetailSource,
|
||||
VisualColumn,
|
||||
JoinKeyPair,
|
||||
} from "@/types/report";
|
||||
|
||||
interface SchemaTable {
|
||||
table_name: string;
|
||||
table_type: string;
|
||||
}
|
||||
|
||||
interface SchemaColumn {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}
|
||||
|
||||
interface ForeignKey {
|
||||
constraint_name: string;
|
||||
columns: string[];
|
||||
foreign_table: string;
|
||||
foreign_columns: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_DATA_SOURCE: VisualDataSource = {
|
||||
master: { tableName: "", columns: [] },
|
||||
details: [],
|
||||
};
|
||||
|
||||
function buildSqlForMaster(ds: VisualDataSource): string {
|
||||
if (!ds.master.tableName || ds.master.columns.length === 0) return "";
|
||||
const cols = ds.master.columns.map((c) => `"${c.name}"`).join(", ");
|
||||
return `SELECT ${cols} FROM "${ds.master.tableName}" WHERE 1=1`;
|
||||
}
|
||||
|
||||
function buildSqlForDetail(ds: VisualDataSource, detail: VisualDetailSource): string {
|
||||
if (!detail.tableName || detail.columns.length === 0) return "";
|
||||
const cols = detail.columns.map((c) => `"${c.name}"`).join(", ");
|
||||
const conditions = detail.joinKeys
|
||||
.filter((jk) => jk.detailColumn)
|
||||
.map((jk, idx) => `"${jk.detailColumn}" = $${idx + 1}`)
|
||||
.join(" AND ");
|
||||
const where = conditions ? ` WHERE ${conditions}` : "";
|
||||
return `SELECT ${cols} FROM "${detail.tableName}"${where}`;
|
||||
}
|
||||
|
||||
function buildQueriesFromDataSource(ds: VisualDataSource): ReportQuery[] {
|
||||
const queries: ReportQuery[] = [];
|
||||
|
||||
const masterSql = buildSqlForMaster(ds);
|
||||
if (masterSql) {
|
||||
queries.push({
|
||||
id: `vds_master_${ds.master.tableName}`,
|
||||
name: `${ds.master.tableName} (마스터)`,
|
||||
type: "MASTER",
|
||||
sqlQuery: masterSql,
|
||||
parameters: [],
|
||||
visualDataSource: ds,
|
||||
});
|
||||
}
|
||||
|
||||
for (const detail of ds.details) {
|
||||
const detailSql = buildSqlForDetail(ds, detail);
|
||||
if (detailSql) {
|
||||
const params = detail.joinKeys
|
||||
.filter((jk) => jk.detailColumn)
|
||||
.map((_, idx) => `$${idx + 1}`);
|
||||
queries.push({
|
||||
id: `vds_detail_${detail.id}`,
|
||||
name: `${detail.tableName} (디테일)`,
|
||||
type: "DETAIL",
|
||||
sqlQuery: detailSql,
|
||||
parameters: params,
|
||||
visualDataSource: ds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
export function VisualDataSourceBuilder({ component, embedded = false }: Props) {
|
||||
const { updateComponent, queries, setQueries } = useReportDesigner();
|
||||
|
||||
const ds: VisualDataSource = component.visualDataSource ?? EMPTY_DATA_SOURCE;
|
||||
const prevDsRef = useRef<string>("");
|
||||
|
||||
// 데이터 소스 변경 시 queries 배열 자동 동기화
|
||||
useEffect(() => {
|
||||
const dsJson = JSON.stringify(ds);
|
||||
if (dsJson === prevDsRef.current) return;
|
||||
prevDsRef.current = dsJson;
|
||||
|
||||
if (!ds.master.tableName) return;
|
||||
|
||||
const vdsQueries = buildQueriesFromDataSource(ds);
|
||||
if (vdsQueries.length === 0) return;
|
||||
|
||||
const nonVdsQueries = queries.filter((q) => !q.id.startsWith("vds_"));
|
||||
setQueries([...nonVdsQueries, ...vdsQueries]);
|
||||
}, [ds, queries, setQueries]);
|
||||
|
||||
const [tables, setTables] = useState<SchemaTable[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [masterColumns, setMasterColumns] = useState<SchemaColumn[]>([]);
|
||||
const [masterColumnsLoading, setMasterColumnsLoading] = useState(false);
|
||||
const [detailColumnsMap, setDetailColumnsMap] = useState<Record<string, SchemaColumn[]>>({});
|
||||
const [detailColumnsLoading, setDetailColumnsLoading] = useState<Record<string, boolean>>({});
|
||||
const [detailFkMap, setDetailFkMap] = useState<Record<string, ForeignKey[]>>({});
|
||||
const [expandedDetails, setExpandedDetails] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 복원 시 디테일 테이블의 컬럼 목록 자동 로드
|
||||
useEffect(() => {
|
||||
for (const detail of ds.details) {
|
||||
if (detail.tableName && !detailColumnsMap[detail.id]) {
|
||||
setDetailColumnsLoading((prev) => ({ ...prev, [detail.id]: true }));
|
||||
reportApi
|
||||
.getSchemaTableColumns(detail.tableName)
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
setDetailColumnsMap((prev) => ({ ...prev, [detail.id]: res.data }));
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setDetailColumnsLoading((prev) => ({ ...prev, [detail.id]: false }));
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [ds.details, detailColumnsMap]);
|
||||
|
||||
const updateDS = useCallback(
|
||||
(patch: Partial<VisualDataSource>) => {
|
||||
updateComponent(component.id, {
|
||||
visualDataSource: { ...ds, ...patch },
|
||||
});
|
||||
},
|
||||
[component.id, ds, updateComponent],
|
||||
);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setTablesLoading(true);
|
||||
reportApi
|
||||
.getSchemaTableList()
|
||||
.then((res) => {
|
||||
if (!cancelled && res.success) setTables(res.data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setTablesLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// 마스터 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (!ds.master.tableName) {
|
||||
setMasterColumns([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setMasterColumnsLoading(true);
|
||||
reportApi
|
||||
.getSchemaTableColumns(ds.master.tableName)
|
||||
.then((res) => {
|
||||
if (!cancelled && res.success) setMasterColumns(res.data);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
if (!cancelled) setMasterColumnsLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [ds.master.tableName]);
|
||||
|
||||
const handleMasterTableChange = useCallback(
|
||||
(tableName: string) => {
|
||||
const newTableName = tableName === "none" ? "" : tableName;
|
||||
updateDS({
|
||||
master: { tableName: newTableName, columns: [] },
|
||||
details: [],
|
||||
});
|
||||
setDetailColumnsMap({});
|
||||
setDetailFkMap({});
|
||||
},
|
||||
[updateDS],
|
||||
);
|
||||
|
||||
const toggleMasterColumn = useCallback(
|
||||
(colName: string, dataType: string) => {
|
||||
const existing = ds.master.columns.find((c) => c.name === colName);
|
||||
const newColumns = existing
|
||||
? ds.master.columns.filter((c) => c.name !== colName)
|
||||
: [...ds.master.columns, { name: colName, label: colName, dataType, selected: true }];
|
||||
updateDS({ master: { ...ds.master, columns: newColumns } });
|
||||
},
|
||||
[ds, updateDS],
|
||||
);
|
||||
|
||||
const updateMasterColumnLabel = useCallback(
|
||||
(colName: string, label: string) => {
|
||||
const newColumns = ds.master.columns.map((c) =>
|
||||
c.name === colName ? { ...c, label } : c,
|
||||
);
|
||||
updateDS({ master: { ...ds.master, columns: newColumns } });
|
||||
},
|
||||
[ds, updateDS],
|
||||
);
|
||||
|
||||
// 디테일 추가
|
||||
const addDetail = useCallback(() => {
|
||||
const newDetail: VisualDetailSource = {
|
||||
id: `detail_${Date.now()}`,
|
||||
tableName: "",
|
||||
columns: [],
|
||||
joinKeys: [],
|
||||
};
|
||||
updateDS({ details: [...ds.details, newDetail] });
|
||||
setExpandedDetails((prev) => ({ ...prev, [newDetail.id]: true }));
|
||||
}, [ds, updateDS]);
|
||||
|
||||
const removeDetail = useCallback(
|
||||
(detailId: string) => {
|
||||
updateDS({ details: ds.details.filter((d) => d.id !== detailId) });
|
||||
},
|
||||
[ds, updateDS],
|
||||
);
|
||||
|
||||
const updateDetail = useCallback(
|
||||
(detailId: string, patch: Partial<VisualDetailSource>) => {
|
||||
updateDS({
|
||||
details: ds.details.map((d) => (d.id === detailId ? { ...d, ...patch } : d)),
|
||||
});
|
||||
},
|
||||
[ds, updateDS],
|
||||
);
|
||||
|
||||
// 디테일 테이블 변경 시 컬럼 + FK 로드
|
||||
const handleDetailTableChange = useCallback(
|
||||
async (detailId: string, tableName: string) => {
|
||||
const newTableName = tableName === "none" ? "" : tableName;
|
||||
updateDetail(detailId, { tableName: newTableName, columns: [], joinKeys: [] });
|
||||
|
||||
if (!newTableName) return;
|
||||
|
||||
setDetailColumnsLoading((prev) => ({ ...prev, [detailId]: true }));
|
||||
try {
|
||||
const [colRes, fkRes] = await Promise.all([
|
||||
reportApi.getSchemaTableColumns(newTableName),
|
||||
reportApi.getSchemaTableForeignKeys(newTableName),
|
||||
]);
|
||||
|
||||
if (colRes.success) {
|
||||
setDetailColumnsMap((prev) => ({ ...prev, [detailId]: colRes.data }));
|
||||
}
|
||||
|
||||
if (fkRes.success) {
|
||||
setDetailFkMap((prev) => ({ ...prev, [detailId]: fkRes.data }));
|
||||
|
||||
// FK 자동 감지: 마스터 테이블을 참조하는 FK 찾기
|
||||
const matchingFk = fkRes.data.find(
|
||||
(fk: ForeignKey) => fk.foreign_table === ds.master.tableName,
|
||||
);
|
||||
if (matchingFk) {
|
||||
const autoJoinKeys: JoinKeyPair[] = matchingFk.columns.map(
|
||||
(col: string, idx: number) => ({
|
||||
masterColumn: matchingFk.foreign_columns[idx] || "",
|
||||
detailColumn: col,
|
||||
autoDetected: true,
|
||||
}),
|
||||
);
|
||||
updateDetail(detailId, {
|
||||
tableName: newTableName,
|
||||
columns: [],
|
||||
joinKeys: autoJoinKeys,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 무시
|
||||
} finally {
|
||||
setDetailColumnsLoading((prev) => ({ ...prev, [detailId]: false }));
|
||||
}
|
||||
},
|
||||
[ds.master.tableName, updateDetail],
|
||||
);
|
||||
|
||||
const toggleDetailColumn = useCallback(
|
||||
(detailId: string, colName: string, dataType: string) => {
|
||||
const detail = ds.details.find((d) => d.id === detailId);
|
||||
if (!detail) return;
|
||||
const existing = detail.columns.find((c) => c.name === colName);
|
||||
const newColumns = existing
|
||||
? detail.columns.filter((c) => c.name !== colName)
|
||||
: [...detail.columns, { name: colName, label: colName, dataType, selected: true }];
|
||||
updateDetail(detailId, { columns: newColumns });
|
||||
},
|
||||
[ds.details, updateDetail],
|
||||
);
|
||||
|
||||
const updateDetailColumnLabel = useCallback(
|
||||
(detailId: string, colName: string, label: string) => {
|
||||
const detail = ds.details.find((d) => d.id === detailId);
|
||||
if (!detail) return;
|
||||
const newColumns = detail.columns.map((c) =>
|
||||
c.name === colName ? { ...c, label } : c,
|
||||
);
|
||||
updateDetail(detailId, { columns: newColumns });
|
||||
},
|
||||
[ds.details, updateDetail],
|
||||
);
|
||||
|
||||
// 연결 키 관리
|
||||
const addJoinKey = useCallback(
|
||||
(detailId: string) => {
|
||||
const detail = ds.details.find((d) => d.id === detailId);
|
||||
if (!detail) return;
|
||||
updateDetail(detailId, {
|
||||
joinKeys: [
|
||||
...detail.joinKeys,
|
||||
{ masterColumn: "", detailColumn: "", autoDetected: false },
|
||||
],
|
||||
});
|
||||
},
|
||||
[ds.details, updateDetail],
|
||||
);
|
||||
|
||||
const updateJoinKey = useCallback(
|
||||
(detailId: string, keyIdx: number, patch: Partial<JoinKeyPair>) => {
|
||||
const detail = ds.details.find((d) => d.id === detailId);
|
||||
if (!detail) return;
|
||||
const newKeys = detail.joinKeys.map((k, i) =>
|
||||
i === keyIdx ? { ...k, ...patch, autoDetected: false } : k,
|
||||
);
|
||||
updateDetail(detailId, { joinKeys: newKeys });
|
||||
},
|
||||
[ds.details, updateDetail],
|
||||
);
|
||||
|
||||
const removeJoinKey = useCallback(
|
||||
(detailId: string, keyIdx: number) => {
|
||||
const detail = ds.details.find((d) => d.id === detailId);
|
||||
if (!detail) return;
|
||||
updateDetail(detailId, {
|
||||
joinKeys: detail.joinKeys.filter((_, i) => i !== keyIdx),
|
||||
});
|
||||
},
|
||||
[ds.details, updateDetail],
|
||||
);
|
||||
|
||||
const toggleDetailExpanded = useCallback((detailId: string) => {
|
||||
setExpandedDetails((prev) => ({ ...prev, [detailId]: !prev[detailId] }));
|
||||
}, []);
|
||||
|
||||
// 컬럼 체크박스 렌더링 헬퍼
|
||||
const renderColumnCheckboxes = (
|
||||
schemaColumns: SchemaColumn[],
|
||||
selectedColumns: VisualColumn[],
|
||||
onToggle: (colName: string, dataType: string) => void,
|
||||
onLabelChange: (colName: string, label: string) => void,
|
||||
loading: boolean,
|
||||
) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-xs text-muted-foreground">컬럼 로딩 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (schemaColumns.length === 0) {
|
||||
return <p className="py-2 text-xs text-muted-foreground">테이블을 선택해주세요.</p>;
|
||||
}
|
||||
return (
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{schemaColumns.map((col) => {
|
||||
const selected = selectedColumns.find((c) => c.name === col.column_name);
|
||||
return (
|
||||
<div key={col.column_name} className="flex items-center gap-2 rounded px-1 py-0.5 hover:bg-muted/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!selected}
|
||||
onChange={() => onToggle(col.column_name, col.data_type)}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-xs">{col.column_name}</span>
|
||||
<span className="shrink-0 text-[9px] text-gray-400">{col.data_type}</span>
|
||||
{selected && (
|
||||
<Input
|
||||
value={selected.label}
|
||||
onChange={(e) => onLabelChange(col.column_name, e.target.value)}
|
||||
placeholder="라벨"
|
||||
className="h-6 w-24 text-[10px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div className="space-y-3">
|
||||
{/* 마스터 데이터 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">마스터 테이블</Label>
|
||||
<Select
|
||||
value={ds.master.tableName || "none"}
|
||||
onValueChange={handleMasterTableChange}
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder={tablesLoading ? "로딩 중..." : "테이블 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.table_name} value={t.table_name}>
|
||||
{t.table_name}
|
||||
{t.table_type === "VIEW" && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">(뷰)</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{ds.master.tableName && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">
|
||||
마스터 컬럼
|
||||
{ds.master.columns.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 text-[10px]">
|
||||
{ds.master.columns.length}개
|
||||
</Badge>
|
||||
)}
|
||||
</Label>
|
||||
{renderColumnCheckboxes(
|
||||
masterColumns,
|
||||
ds.master.columns,
|
||||
toggleMasterColumn,
|
||||
updateMasterColumnLabel,
|
||||
masterColumnsLoading,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 디테일 데이터 */}
|
||||
{ds.details.map((detail, idx) => {
|
||||
const detailCols = detailColumnsMap[detail.id] || [];
|
||||
const isLoading = detailColumnsLoading[detail.id] || false;
|
||||
const isExpanded = expandedDetails[detail.id] !== false;
|
||||
|
||||
return (
|
||||
<div key={detail.id} className="rounded-lg border border-gray-200 bg-white p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleDetailExpanded(detail.id)}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Table2 className="h-3.5 w-3.5 text-gray-500" />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
디테일 #{idx + 1}
|
||||
{detail.tableName && (
|
||||
<span className="ml-1 font-normal text-muted-foreground">
|
||||
({detail.tableName})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-3 w-3 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-red-400 hover:bg-red-50 hover:text-red-600"
|
||||
onClick={() => removeDetail(detail.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">테이블</Label>
|
||||
<Select
|
||||
value={detail.tableName || "none"}
|
||||
onValueChange={(v) => handleDetailTableChange(detail.id, v)}
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.table_name} value={t.table_name}>
|
||||
{t.table_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{detail.tableName && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50/50 p-2.5">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link2 className="h-3 w-3 text-blue-600" />
|
||||
<span className="text-[11px] font-medium text-blue-700">연결 키</span>
|
||||
{detail.joinKeys.some((k) => k.autoDetected) && (
|
||||
<Badge variant="outline" className="h-4 border-blue-300 px-1 text-[9px] text-blue-600">
|
||||
자동 감지
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-[10px]"
|
||||
onClick={() => addJoinKey(detail.id)}
|
||||
>
|
||||
<Plus className="mr-0.5 h-2.5 w-2.5" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{detail.joinKeys.length === 0 && (
|
||||
<p className="text-[10px] text-blue-600">
|
||||
FK가 감지되지 않았습니다. 수동으로 추가해주세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
{detail.joinKeys.map((jk, ki) => (
|
||||
<div key={ki} className="flex items-center gap-1">
|
||||
<Select
|
||||
value={jk.masterColumn || "none"}
|
||||
onValueChange={(v) =>
|
||||
updateJoinKey(detail.id, ki, {
|
||||
masterColumn: v === "none" ? "" : v,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-[10px]">
|
||||
<SelectValue placeholder="마스터 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{ds.master.columns.length > 0
|
||||
? ds.master.columns.map((c) => (
|
||||
<SelectItem key={c.name} value={c.name}>
|
||||
{c.label !== c.name ? `${c.label} (${c.name})` : c.name}
|
||||
</SelectItem>
|
||||
))
|
||||
: masterColumns.map((c) => (
|
||||
<SelectItem key={c.column_name} value={c.column_name}>
|
||||
{c.column_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-[10px] text-gray-400">↔</span>
|
||||
<Select
|
||||
value={jk.detailColumn || "none"}
|
||||
onValueChange={(v) =>
|
||||
updateJoinKey(detail.id, ki, {
|
||||
detailColumn: v === "none" ? "" : v,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-[10px]">
|
||||
<SelectValue placeholder="디테일 컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{detailCols.map((c) => (
|
||||
<SelectItem key={c.column_name} value={c.column_name}>
|
||||
{c.column_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 shrink-0 text-red-400 hover:text-red-600"
|
||||
onClick={() => removeJoinKey(detail.id, ki)}
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.tableName && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">
|
||||
디테일 컬럼
|
||||
{detail.columns.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 text-[10px]">
|
||||
{detail.columns.length}개
|
||||
</Badge>
|
||||
)}
|
||||
</Label>
|
||||
{renderColumnCheckboxes(
|
||||
detailCols,
|
||||
detail.columns,
|
||||
(colName, dataType) => toggleDetailColumn(detail.id, colName, dataType),
|
||||
(colName, label) => updateDetailColumnLabel(detail.id, colName, label),
|
||||
isLoading,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{ds.master.tableName && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-dashed text-xs"
|
||||
onClick={addDetail}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
디테일 데이터 추가
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!ds.master.tableName && (
|
||||
<div className="rounded-lg border border-dashed border-gray-200 p-4 text-center">
|
||||
<Database className="mx-auto mb-1.5 h-6 w-6 text-gray-300" />
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
마스터 테이블을 선택하면 데이터 소스를 구성할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (embedded) return content;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">{content}</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* shared.tsx — 모달 내부 공통 UI 헬퍼 컴포넌트
|
||||
*
|
||||
* Section, TabContent, Field, FieldGroup, Grid, InfoBox, PreviewPanel 등
|
||||
* 모든 컴포넌트 설정 모달에서 일관된 UI를 유지하기 위한 빌딩 블록.
|
||||
*/
|
||||
|
||||
import React, { type ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
// ─── 섹션 컨테이너 ────────────────────────────────────────────────────────────
|
||||
|
||||
interface SectionProps {
|
||||
emphasis?: boolean;
|
||||
icon?: ReactNode;
|
||||
title?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
emphasis = false,
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
}: SectionProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border p-4",
|
||||
emphasis
|
||||
? "border-blue-200 bg-blue-50/50"
|
||||
: "border-gray-200 bg-white",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{(icon || title) && (
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{icon && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
emphasis ? "text-blue-600" : "text-gray-500",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{title && (
|
||||
<p
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
emphasis ? "text-blue-700" : "text-foreground",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 탭 콘텐츠 래퍼 ──────────────────────────────────────────────────────────
|
||||
|
||||
interface TabContentProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TabContent({ children, className }: TabContentProps) {
|
||||
return (
|
||||
<div className={cn("space-y-4 px-6 py-5", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 폼 필드 래퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
help?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
htmlFor?: string;
|
||||
}
|
||||
|
||||
export function Field({ label, help, children, className, htmlFor }: FieldProps) {
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
<Label
|
||||
htmlFor={htmlFor}
|
||||
className="block text-xs font-medium text-foreground"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
{help && (
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">{help}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 2/3열 그리드 ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface GridProps {
|
||||
children: ReactNode;
|
||||
cols?: 2 | 3;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Grid({ children, cols = 2, className }: GridProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3",
|
||||
cols === 2 ? "grid-cols-2" : "grid-cols-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 필드 그룹 (space-y-3) ───────────────────────────────────────────────────
|
||||
|
||||
interface FieldGroupProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FieldGroup({ children, className }: FieldGroupProps) {
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 인라인 정보 박스 ─────────────────────────────────────────────────────────
|
||||
|
||||
interface InfoBoxProps {
|
||||
children: ReactNode;
|
||||
variant?: "blue" | "gray";
|
||||
}
|
||||
|
||||
export function InfoBox({ children, variant = "blue" }: InfoBoxProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-xs",
|
||||
variant === "blue"
|
||||
? "border-blue-200 bg-blue-50 text-blue-800"
|
||||
: "border-gray-200 bg-gray-50 text-gray-600",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 미리보기 패널 ────────────────────────────────────────────────────────────
|
||||
|
||||
interface PreviewPanelProps {
|
||||
children?: ReactNode;
|
||||
label?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export function PreviewPanel({
|
||||
children,
|
||||
label = "미리보기",
|
||||
height = "h-48",
|
||||
}: PreviewPanelProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
{children ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center overflow-hidden rounded-xl border border-gray-200 bg-white",
|
||||
height,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center rounded-xl border-2 border-dashed border-gray-200 bg-white",
|
||||
height,
|
||||
)}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { QrCode, X } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
export function BarcodeProperties({ component, section }: Props) {
|
||||
const { updateComponent, queries, getQueryResult } = useReportDesigner();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 바코드 스타일 — 우측 패널(section="style")에서 표시 */}
|
||||
{showStyle && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-cyan-200 bg-cyan-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-cyan-700">
|
||||
<QrCode className="h-4 w-4" />
|
||||
바코드 스타일
|
||||
</div>
|
||||
|
||||
{/* 1D 바코드 전용: 텍스트 표시 */}
|
||||
{component.barcodeType !== "QR" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="showBarcodeText"
|
||||
checked={component.showBarcodeText !== false}
|
||||
onChange={(e) => updateComponent(component.id, { showBarcodeText: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<Label htmlFor="showBarcodeText" className="text-xs">
|
||||
바코드 아래 텍스트 표시
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 색상 설정 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">바코드 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={component.barcodeColor || "#000000"}
|
||||
onChange={(e) => updateComponent(component.id, { barcodeColor: e.target.value })}
|
||||
className="h-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">배경 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={component.barcodeBackground || "#ffffff"}
|
||||
onChange={(e) => updateComponent(component.id, { barcodeBackground: e.target.value })}
|
||||
className="h-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 여백 */}
|
||||
<div>
|
||||
<Label className="text-xs">여백 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.barcodeMargin ?? 10}
|
||||
onChange={(e) => updateComponent(component.id, { barcodeMargin: Number(e.target.value) })}
|
||||
min={0}
|
||||
max={50}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 바코드 데이터 — 모달(section="data")에서 표시 */}
|
||||
{showData && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-cyan-200 bg-cyan-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-cyan-700">
|
||||
<QrCode className="h-4 w-4" />
|
||||
바코드 데이터
|
||||
</div>
|
||||
|
||||
{/* 바코드 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">바코드 타입</Label>
|
||||
<Select
|
||||
value={component.barcodeType || "CODE128"}
|
||||
onValueChange={(value) => {
|
||||
const newType = value as "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
|
||||
if (newType === "QR") {
|
||||
const size = Math.max(component.width, component.height);
|
||||
updateComponent(component.id, { barcodeType: newType, width: size, height: size });
|
||||
} else {
|
||||
updateComponent(component.id, { barcodeType: newType });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="CODE128">CODE128 (범용)</SelectItem>
|
||||
<SelectItem value="CODE39">CODE39 (산업용)</SelectItem>
|
||||
<SelectItem value="EAN13">EAN-13 (상품)</SelectItem>
|
||||
<SelectItem value="EAN8">EAN-8 (소형상품)</SelectItem>
|
||||
<SelectItem value="UPC">UPC (북미상품)</SelectItem>
|
||||
<SelectItem value="QR">QR코드</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* QR 오류 보정 수준 */}
|
||||
{component.barcodeType === "QR" && (
|
||||
<div>
|
||||
<Label className="text-xs">오류 보정 수준</Label>
|
||||
<Select
|
||||
value={component.qrErrorCorrectionLevel || "M"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, { qrErrorCorrectionLevel: value as "L" | "M" | "Q" | "H" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="L">L (7% 복구)</SelectItem>
|
||||
<SelectItem value="M">M (15% 복구)</SelectItem>
|
||||
<SelectItem value="Q">Q (25% 복구)</SelectItem>
|
||||
<SelectItem value="H">H (30% 복구)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[10px] text-gray-500">높을수록 손상에 강하지만 크기 증가</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 바코드 값 입력 (쿼리 연결 없을 때) */}
|
||||
{!component.queryId && (
|
||||
<div>
|
||||
<Label className="text-xs">바코드 값</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={component.barcodeValue || ""}
|
||||
onChange={(e) => updateComponent(component.id, { barcodeValue: e.target.value })}
|
||||
placeholder={
|
||||
component.barcodeType === "EAN13"
|
||||
? "13자리 숫자"
|
||||
: component.barcodeType === "EAN8"
|
||||
? "8자리 숫자"
|
||||
: component.barcodeType === "UPC"
|
||||
? "12자리 숫자"
|
||||
: "바코드에 표시할 값"
|
||||
}
|
||||
className="h-9"
|
||||
/>
|
||||
{(component.barcodeType === "EAN13" ||
|
||||
component.barcodeType === "EAN8" ||
|
||||
component.barcodeType === "UPC") && (
|
||||
<p className="mt-1 text-[10px] text-gray-500">
|
||||
{component.barcodeType === "EAN13" && "EAN-13: 12~13자리 숫자 필요"}
|
||||
{component.barcodeType === "EAN8" && "EAN-8: 7~8자리 숫자 필요"}
|
||||
{component.barcodeType === "UPC" && "UPC: 11~12자리 숫자 필요"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 쿼리 연결 안내 — 인라인 안내 텍스트 (수정하지 않음) */}
|
||||
{!component.queryId && (
|
||||
<div className="rounded border border-cyan-200 bg-cyan-100 p-2 text-xs text-cyan-800">
|
||||
쿼리를 연결하면 데이터베이스 값으로 바코드를 생성할 수 있습니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 쿼리 연결 시 필드 선택 */}
|
||||
{component.queryId && (
|
||||
<>
|
||||
{/* QR코드: 다중 필드 모드 토글 */}
|
||||
{component.barcodeType === "QR" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="qrUseMultiField"
|
||||
checked={component.qrUseMultiField === true}
|
||||
onChange={(e) =>
|
||||
updateComponent(component.id, {
|
||||
qrUseMultiField: e.target.checked,
|
||||
...(e.target.checked && { barcodeFieldName: "" }),
|
||||
})
|
||||
}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<Label htmlFor="qrUseMultiField" className="text-xs">
|
||||
다중 필드 (JSON 형식)
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 단일 필드 모드 (1D 바코드 또는 QR 단일 모드) */}
|
||||
{(component.barcodeType !== "QR" || !component.qrUseMultiField) && (
|
||||
<div>
|
||||
<Label className="text-xs">바인딩 필드</Label>
|
||||
<Select
|
||||
value={component.barcodeFieldName || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, { barcodeFieldName: value === "none" ? "" : value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{(() => {
|
||||
const query = queries.find((q) => q.id === component.queryId);
|
||||
const result = query ? getQueryResult(query.id) : null;
|
||||
if (result && result.fields) {
|
||||
return result.fields.map((field: string) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
</SelectItem>
|
||||
));
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR코드 다중 필드 모드 UI */}
|
||||
{component.barcodeType === "QR" && component.qrUseMultiField && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">JSON 필드 매핑</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
const currentFields = component.qrDataFields || [];
|
||||
updateComponent(component.id, {
|
||||
qrDataFields: [...currentFields, { fieldName: "", label: "" }],
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ 필드 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="max-h-[200px] space-y-2 overflow-y-auto">
|
||||
{(component.qrDataFields || []).map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-1 rounded border p-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Select
|
||||
value={field.fieldName || "none"}
|
||||
onValueChange={(value) => {
|
||||
const newFields = [...(component.qrDataFields || [])];
|
||||
newFields[index] = {
|
||||
...newFields[index],
|
||||
fieldName: value === "none" ? "" : value,
|
||||
label: newFields[index].label || (value === "none" ? "" : value),
|
||||
};
|
||||
updateComponent(component.id, { qrDataFields: newFields });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{(() => {
|
||||
const query = queries.find((q) => q.id === component.queryId);
|
||||
const result = query ? getQueryResult(query.id) : null;
|
||||
if (result && result.fields) {
|
||||
return result.fields.map((f: string) => (
|
||||
<SelectItem key={f} value={f}>
|
||||
{f}
|
||||
</SelectItem>
|
||||
));
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
value={field.label || ""}
|
||||
onChange={(e) => {
|
||||
const newFields = [...(component.qrDataFields || [])];
|
||||
newFields[index] = { ...newFields[index], label: e.target.value };
|
||||
updateComponent(component.id, { qrDataFields: newFields });
|
||||
}}
|
||||
placeholder="JSON 키 이름"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
|
||||
onClick={() => {
|
||||
const newFields = (component.qrDataFields || []).filter((_, i) => i !== index);
|
||||
updateComponent(component.id, { qrDataFields: newFields });
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(component.qrDataFields || []).length === 0 && (
|
||||
<p className="text-center text-xs text-gray-400">필드를 추가하세요</p>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-gray-500">
|
||||
결과:{" "}
|
||||
{component.qrIncludeAllRows
|
||||
? `[{"${(component.qrDataFields || []).map((f) => f.label || "key").join('":"값","')}"}, ...]`
|
||||
: `{"${(component.qrDataFields || []).map((f) => f.label || "key").join('":"값","')}":"값"}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR코드 모든 행 포함 옵션 */}
|
||||
{component.barcodeType === "QR" && component.queryId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="qrIncludeAllRows"
|
||||
checked={component.qrIncludeAllRows === true}
|
||||
onChange={(e) => updateComponent(component.id, { qrIncludeAllRows: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<Label htmlFor="qrIncludeAllRows" className="text-xs">
|
||||
모든 행 포함 (배열)
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Calculator } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
export function CalculationProperties({ component, section }: Props) {
|
||||
const { updateComponent, queries, getQueryResult } = useReportDesigner();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 표시 설정 — 우측 패널(section="style")에서 표시 */}
|
||||
{showStyle && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-orange-200 bg-orange-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-orange-700">
|
||||
<Calculator className="h-4 w-4" />
|
||||
표시 설정
|
||||
</div>
|
||||
|
||||
{/* 라벨 너비 */}
|
||||
<div>
|
||||
<Label className="text-xs">라벨 너비 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.labelWidth || 120}
|
||||
onChange={(e) => updateComponent(component.id, { labelWidth: Number(e.target.value) })}
|
||||
min={60}
|
||||
max={200}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 숫자 포맷 */}
|
||||
<div>
|
||||
<Label className="text-xs">숫자 포맷</Label>
|
||||
<Select
|
||||
value={component.numberFormat || "currency"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, { numberFormat: value as "none" | "comma" | "currency" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="comma">천단위 구분</SelectItem>
|
||||
<SelectItem value="currency">통화 (원)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 통화 접미사 */}
|
||||
{component.numberFormat === "currency" && (
|
||||
<div>
|
||||
<Label className="text-xs">통화 단위</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={component.currencySuffix || "원"}
|
||||
onChange={(e) => updateComponent(component.id, { currencySuffix: e.target.value })}
|
||||
placeholder="원"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 폰트 크기 설정 */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">라벨 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.labelFontSize || 13}
|
||||
onChange={(e) => updateComponent(component.id, { labelFontSize: Number(e.target.value) })}
|
||||
min={10}
|
||||
max={20}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">값 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.valueFontSize || 13}
|
||||
onChange={(e) => updateComponent(component.id, { valueFontSize: Number(e.target.value) })}
|
||||
min={10}
|
||||
max={20}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">결과 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.resultFontSize || 16}
|
||||
onChange={(e) => updateComponent(component.id, { resultFontSize: Number(e.target.value) })}
|
||||
min={12}
|
||||
max={24}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 색상 설정 */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">라벨 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={component.labelColor || "#374151"}
|
||||
onChange={(e) => updateComponent(component.id, { labelColor: e.target.value })}
|
||||
className="h-9 w-full cursor-pointer p-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">값 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={component.valueColor || "#000000"}
|
||||
onChange={(e) => updateComponent(component.id, { valueColor: e.target.value })}
|
||||
className="h-9 w-full cursor-pointer p-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">결과 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={component.resultColor || "#2563eb"}
|
||||
onChange={(e) => updateComponent(component.id, { resultColor: e.target.value })}
|
||||
className="h-9 w-full cursor-pointer p-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 계산 항목 — 모달(section="data")에서 표시 */}
|
||||
{showData && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-orange-200 bg-orange-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-orange-700">
|
||||
<Calculator className="h-4 w-4" />
|
||||
계산 항목
|
||||
</div>
|
||||
|
||||
{/* 결과 라벨 */}
|
||||
<div>
|
||||
<Label className="text-xs">결과 라벨</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={component.resultLabel || "합계"}
|
||||
onChange={(e) => updateComponent(component.id, { resultLabel: e.target.value })}
|
||||
placeholder="합계 금액"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 선택 (데이터 바인딩용) */}
|
||||
<div>
|
||||
<Label className="text-xs">데이터 소스 (쿼리)</Label>
|
||||
<Select
|
||||
value={component.queryId || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, { queryId: value === "none" ? undefined : value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="쿼리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">직접 입력</SelectItem>
|
||||
{queries.map((q) => (
|
||||
<SelectItem key={q.id} value={q.id}>
|
||||
{q.name} ({q.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 계산 항목 목록 관리 */}
|
||||
<div className="border-t pt-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold">항목 목록</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => {
|
||||
const currentItems = component.calcItems || [];
|
||||
updateComponent(component.id, {
|
||||
calcItems: [
|
||||
...currentItems,
|
||||
{
|
||||
label: `항목${currentItems.length + 1}`,
|
||||
value: 0,
|
||||
operator: "+" as const,
|
||||
fieldName: "",
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ 항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 항목 리스트 — 개별 항목 카드(rounded border bg-white p-2)는 유지 */}
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{(component.calcItems || []).map((item, index: number) => (
|
||||
<div key={index} className="rounded border bg-white p-2">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-medium">항목 {index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||
onClick={() => {
|
||||
const currentItems = [...(component.calcItems || [])];
|
||||
currentItems.splice(index, 1);
|
||||
updateComponent(component.id, { calcItems: currentItems });
|
||||
}}
|
||||
>
|
||||
x
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`grid gap-1 ${index === 0 ? "grid-cols-1" : "grid-cols-3"}`}>
|
||||
<div className={index === 0 ? "" : "col-span-2"}>
|
||||
<Label className="text-[10px]">라벨</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={item.label}
|
||||
onChange={(e) => {
|
||||
const currentItems = [...(component.calcItems || [])];
|
||||
currentItems[index] = { ...currentItems[index], label: e.target.value };
|
||||
updateComponent(component.id, { calcItems: currentItems });
|
||||
}}
|
||||
className="h-6 text-xs"
|
||||
placeholder="항목명"
|
||||
/>
|
||||
</div>
|
||||
{index > 0 && (
|
||||
<div>
|
||||
<Label className="text-[10px]">연산자</Label>
|
||||
<Select
|
||||
value={item.operator}
|
||||
onValueChange={(value) => {
|
||||
const currentItems = [...(component.calcItems || [])];
|
||||
currentItems[index] = {
|
||||
...currentItems[index],
|
||||
operator: value as "+" | "-" | "x" | "÷",
|
||||
};
|
||||
updateComponent(component.id, { calcItems: currentItems });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="+">+</SelectItem>
|
||||
<SelectItem value="-">-</SelectItem>
|
||||
<SelectItem value="x">x</SelectItem>
|
||||
<SelectItem value="÷">÷</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{component.queryId ? (
|
||||
<div>
|
||||
<Label className="text-[10px]">필드</Label>
|
||||
<Select
|
||||
value={item.fieldName || "none"}
|
||||
onValueChange={(value) => {
|
||||
const currentItems = [...(component.calcItems || [])];
|
||||
currentItems[index] = {
|
||||
...currentItems[index],
|
||||
fieldName: value === "none" ? "" : value,
|
||||
};
|
||||
updateComponent(component.id, { calcItems: currentItems });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">직접 입력</SelectItem>
|
||||
{(() => {
|
||||
const query = queries.find((q) => q.id === component.queryId);
|
||||
const result = query ? getQueryResult(query.id) : null;
|
||||
if (result && result.fields) {
|
||||
return result.fields.map((field: string) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
</SelectItem>
|
||||
));
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<Label className="text-[10px]">값</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={item.value}
|
||||
onChange={(e) => {
|
||||
const currentItems = [...(component.calcItems || [])];
|
||||
currentItems[index] = {
|
||||
...currentItems[index],
|
||||
value: Number(e.target.value),
|
||||
};
|
||||
updateComponent(component.id, { calcItems: currentItems });
|
||||
}}
|
||||
className="h-6 text-xs"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CardProperties.tsx — 카드 컴포넌트 설정
|
||||
*
|
||||
* - section="data": 모달 내 기능 설정 탭에서 CardLayoutTabs 직접 렌더링
|
||||
* - section="style": 우측 패널에서 프리셋 + 8개 스타일 섹션 제공
|
||||
* - onConfigChange: Draft 모드 — 모달에서 저장 전 로컬 변경용
|
||||
*/
|
||||
|
||||
import { useMemo, useCallback, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig, CardLayoutConfig } from "@/types/report";
|
||||
import { CardLayoutTabs } from "../modals/CardLayoutTabs";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
section?: "style" | "data";
|
||||
onConfigChange?: (updates: Partial<ComponentConfig>) => void;
|
||||
}
|
||||
|
||||
const generateId = () =>
|
||||
`row_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
const DEFAULT_CONFIG: CardLayoutConfig = {
|
||||
tableName: "",
|
||||
primaryKey: "",
|
||||
rows: [{ id: generateId(), gridColumns: 2, elements: [] }],
|
||||
padding: "12px",
|
||||
gap: "8px",
|
||||
borderStyle: "solid",
|
||||
borderColor: "#e5e7eb",
|
||||
backgroundColor: "#ffffff",
|
||||
headerTitleFontSize: 14,
|
||||
headerTitleColor: "#1e40af",
|
||||
labelFontSize: 13,
|
||||
labelColor: "#374151",
|
||||
valueFontSize: 13,
|
||||
valueColor: "#000000",
|
||||
dividerThickness: 1,
|
||||
dividerColor: "#e5e7eb",
|
||||
};
|
||||
|
||||
const CARD_STYLE_PRESETS = {
|
||||
info: {
|
||||
backgroundColor: "#ffffff",
|
||||
borderStyle: "solid",
|
||||
borderColor: "#e5e7eb",
|
||||
borderWidth: 1,
|
||||
accentBorderWidth: 0,
|
||||
borderRadius: "12px",
|
||||
headerFontWeight: "bold" as const,
|
||||
headerTitleColor: "#111827",
|
||||
labelColor: "#6b7280",
|
||||
valueFontWeight: "normal" as const,
|
||||
valueColor: "#111827",
|
||||
dividerColor: "#3b82f6",
|
||||
dividerThickness: 1,
|
||||
},
|
||||
compact: {
|
||||
backgroundColor: "#eff6ff",
|
||||
borderStyle: "none",
|
||||
borderWidth: 0,
|
||||
accentBorderColor: "#3b82f6",
|
||||
accentBorderWidth: 4,
|
||||
borderRadius: "8px",
|
||||
headerFontWeight: "normal" as const,
|
||||
headerTitleColor: "#6b7280",
|
||||
labelColor: "#6b7280",
|
||||
valueFontWeight: "bold" as const,
|
||||
valueColor: "#111827",
|
||||
dividerColor: "#e5e7eb",
|
||||
dividerThickness: 1,
|
||||
},
|
||||
} as const;
|
||||
|
||||
function StyleAccordion({
|
||||
label,
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-100">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex h-10 w-full items-center justify-between px-3 transition-colors ${
|
||||
isOpen
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-bold">{label}</span>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 transition-transform ${isOpen ? "rotate-90" : "text-gray-400"}`}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardProperties({ component, section, onConfigChange }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
const [openStyleSections, setOpenStyleSections] = useState<Set<string>>(new Set(["preset"]));
|
||||
const toggleStyleSection = (id: string) => {
|
||||
setOpenStyleSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const config = useMemo(() => {
|
||||
return component.cardLayoutConfig || DEFAULT_CONFIG;
|
||||
}, [component.cardLayoutConfig]);
|
||||
|
||||
const handleConfigChange = useCallback(
|
||||
(newConfig: CardLayoutConfig) => {
|
||||
const updates = { cardLayoutConfig: newConfig };
|
||||
if (onConfigChange) onConfigChange(updates);
|
||||
else updateComponent(component.id, updates);
|
||||
},
|
||||
[component.id, onConfigChange, updateComponent],
|
||||
);
|
||||
|
||||
const updateDesignConfig = useCallback(
|
||||
(updates: Partial<CardLayoutConfig>) => {
|
||||
handleConfigChange({ ...config, ...updates });
|
||||
},
|
||||
[config, handleConfigChange],
|
||||
);
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(presetKey: keyof typeof CARD_STYLE_PRESETS) => {
|
||||
updateDesignConfig(CARD_STYLE_PRESETS[presetKey]);
|
||||
},
|
||||
[updateDesignConfig],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showData && (
|
||||
<CardLayoutTabs config={config} onConfigChange={handleConfigChange} component={component} onComponentChange={onConfigChange} />
|
||||
)}
|
||||
|
||||
{showStyle && (
|
||||
<div className="mt-2 rounded-lg border border-gray-200 overflow-hidden">
|
||||
<StyleAccordion label="프리셋" isOpen={openStyleSections.has("preset")} onToggle={() => toggleStyleSection("preset")}>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => applyPreset("info")} className="text-xs h-8">인포 카드</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => applyPreset("compact")} className="text-xs h-8">컴팩트 카드</Button>
|
||||
<Button variant="ghost" size="sm" className="text-xs h-8 text-muted-foreground" disabled>커스텀</Button>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
<StyleAccordion label="카드 외형" isOpen={openStyleSections.has("appearance")} onToggle={() => toggleStyleSection("appearance")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">배경색</Label>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||||
<input type="color" value={config.backgroundColor || "#ffffff"} onChange={(e) => updateDesignConfig({ backgroundColor: e.target.value })} className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0" />
|
||||
<Input value={config.backgroundColor || "#ffffff"} onChange={(e) => updateDesignConfig({ backgroundColor: e.target.value })} className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">모서리</Label>
|
||||
<Select value={config.borderRadius || "0"} onValueChange={(v) => updateDesignConfig({ borderRadius: v })}>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">없음</SelectItem>
|
||||
<SelectItem value="8px">보통</SelectItem>
|
||||
<SelectItem value="20px">둥글게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">안쪽 여백</Label>
|
||||
<Select value={config.padding || "12px"} onValueChange={(v) => updateDesignConfig({ padding: v })}>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2px">보통</SelectItem>
|
||||
<SelectItem value="14px">넓게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">행 간격</Label>
|
||||
<Select value={config.gap || "8px"} onValueChange={(v) => updateDesignConfig({ gap: v })}>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1px">보통</SelectItem>
|
||||
<SelectItem value="10px">넓게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
<StyleAccordion label="좌측 액센트 보더" isOpen={openStyleSections.has("accent")} onToggle={() => toggleStyleSection("accent")}>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||
<Label className="text-xs text-gray-500">활성화</Label>
|
||||
<Switch checked={(config.accentBorderWidth ?? 0) > 0} onCheckedChange={(checked) => updateDesignConfig({ accentBorderWidth: checked ? 4 : 0, accentBorderColor: config.accentBorderColor || "#3b82f6" })} />
|
||||
</div>
|
||||
{(config.accentBorderWidth ?? 0) > 0 && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">두께</Label>
|
||||
<Input type="number" min={1} max={10} value={config.accentBorderWidth || 4} onChange={(e) => updateDesignConfig({ accentBorderWidth: parseInt(e.target.value) || 4 })} className="h-9 text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">색상</Label>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||||
<input type="color" value={config.accentBorderColor || "#3b82f6"} onChange={(e) => updateDesignConfig({ accentBorderColor: e.target.value })} className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0" />
|
||||
<Input value={config.accentBorderColor || "#3b82f6"} onChange={(e) => updateDesignConfig({ accentBorderColor: e.target.value })} className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { CheckSquare } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
export function CheckboxProperties({ component, section }: Props) {
|
||||
const { updateComponent, queries, getQueryResult } = useReportDesigner();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 체크박스 스타일 — 우측 패널(section="style")에서 표시 */}
|
||||
{showStyle && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-purple-200 bg-purple-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-purple-700">
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
체크박스 스타일
|
||||
</div>
|
||||
|
||||
{/* 레이블 위치 */}
|
||||
<div>
|
||||
<Label className="text-xs">레이블 위치</Label>
|
||||
<Select
|
||||
value={component.checkboxLabelPosition || "right"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, { checkboxLabelPosition: value as "left" | "right" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 체크박스 크기 */}
|
||||
<div>
|
||||
<Label className="text-xs">체크박스 크기 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.checkboxSize || 18}
|
||||
onChange={(e) => updateComponent(component.id, { checkboxSize: Number(e.target.value) })}
|
||||
min={12}
|
||||
max={40}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 색상 설정 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">체크 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={component.checkboxColor || "#2563eb"}
|
||||
onChange={(e) => updateComponent(component.id, { checkboxColor: e.target.value })}
|
||||
className="h-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">테두리 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={component.checkboxBorderColor || "#6b7280"}
|
||||
onChange={(e) => updateComponent(component.id, { checkboxBorderColor: e.target.value })}
|
||||
className="h-9 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 체크박스 데이터 — 모달(section="data")에서 표시 */}
|
||||
{showData && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-purple-200 bg-purple-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-purple-700">
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
체크박스 데이터
|
||||
</div>
|
||||
|
||||
{/* 체크 상태 (쿼리 연결 없을 때) */}
|
||||
{!component.queryId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="checkboxChecked"
|
||||
checked={component.checkboxChecked === true}
|
||||
onChange={(e) => updateComponent(component.id, { checkboxChecked: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<Label htmlFor="checkboxChecked" className="text-xs">
|
||||
체크됨
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 쿼리 연결 시 필드 선택 */}
|
||||
{component.queryId && (
|
||||
<div>
|
||||
<Label className="text-xs">체크 상태 바인딩 필드</Label>
|
||||
<Select
|
||||
value={component.checkboxFieldName || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, { checkboxFieldName: value === "none" ? "" : value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{(() => {
|
||||
const query = queries.find((q) => q.id === component.queryId);
|
||||
const result = query ? getQueryResult(query.id) : null;
|
||||
if (result && result.fields) {
|
||||
return result.fields.map((field: string) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
</SelectItem>
|
||||
));
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[10px] text-gray-500">true, "Y", 1 등 truthy 값이면 체크됨</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 레이블 텍스트 */}
|
||||
<div>
|
||||
<Label className="text-xs">레이블 텍스트</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={component.checkboxLabel || ""}
|
||||
onChange={(e) => updateComponent(component.id, { checkboxLabel: e.target.value })}
|
||||
placeholder="체크박스 옆 텍스트"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 연결 안내 */}
|
||||
{!component.queryId && (
|
||||
<div className="rounded border border-purple-200 bg-purple-100 p-2 text-xs text-purple-800">
|
||||
쿼리를 연결하면 데이터베이스 값으로 체크 상태를 결정할 수 있습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ComponentStylePanel.tsx — 우측 패널 전용 디자인 설정 컨테이너
|
||||
*
|
||||
* 컴포넌트 타입별 전용 디자인 아코디언이 최상단에 동적 생성되고,
|
||||
* 공통 속성(배치/글꼴/배경/테두리)이 그 아래에 위치한다.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import {
|
||||
ChevronRight,
|
||||
Move,
|
||||
Type as TypeIcon,
|
||||
Square,
|
||||
CreditCard,
|
||||
Table2,
|
||||
ImageIcon,
|
||||
Minus,
|
||||
PenTool,
|
||||
Stamp,
|
||||
Hash,
|
||||
QrCode,
|
||||
CheckSquare,
|
||||
Tag,
|
||||
BarChart3,
|
||||
Lock,
|
||||
Unlock,
|
||||
Layers,
|
||||
ArrowUpToLine,
|
||||
ArrowDownToLine,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Group,
|
||||
Ungroup,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
import { CardProperties } from "./CardProperties";
|
||||
import { DividerProperties } from "./DividerProperties";
|
||||
import { ImageProperties } from "./ImageProperties";
|
||||
import { TableProperties } from "./TableProperties";
|
||||
import { TextProperties } from "./TextProperties";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
text: "텍스트",
|
||||
label: "레이블",
|
||||
table: "테이블",
|
||||
image: "이미지",
|
||||
divider: "구분선",
|
||||
signature: "서명",
|
||||
stamp: "도장",
|
||||
pageNumber: "페이지 번호",
|
||||
card: "카드",
|
||||
calculation: "계산",
|
||||
barcode: "바코드",
|
||||
checkbox: "체크박스",
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
text: <TypeIcon className="h-4 w-4" />,
|
||||
label: <Tag className="h-4 w-4" />,
|
||||
table: <Table2 className="h-4 w-4" />,
|
||||
image: <ImageIcon className="h-4 w-4" />,
|
||||
divider: <Minus className="h-4 w-4" />,
|
||||
signature: <PenTool className="h-4 w-4" />,
|
||||
stamp: <Stamp className="h-4 w-4" />,
|
||||
pageNumber: <Hash className="h-4 w-4" />,
|
||||
card: <CreditCard className="h-4 w-4" />,
|
||||
calculation: <BarChart3 className="h-4 w-4" />,
|
||||
barcode: <QrCode className="h-4 w-4" />,
|
||||
checkbox: <CheckSquare className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
interface AccordionSectionProps {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function AccordionSection({ label, icon, isExpanded, onToggle, children }: AccordionSectionProps) {
|
||||
return (
|
||||
<div className="border-b border-gray-100">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex h-11 w-full items-center justify-between px-4 transition-colors ${
|
||||
isExpanded
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className={isExpanded ? "" : "text-blue-600"}>{icon}</span>
|
||||
<span className="text-sm font-bold">{label}</span>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-90" : "text-gray-400"}`}
|
||||
/>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-4">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeSpecificDesign({
|
||||
component,
|
||||
update,
|
||||
}: {
|
||||
component: ComponentConfig;
|
||||
update: (changes: Partial<ComponentConfig>) => void;
|
||||
}) {
|
||||
const type = component.type;
|
||||
|
||||
if (type === "text" || type === "label") {
|
||||
return (
|
||||
<TextProperties component={component} section="style" />
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "table") {
|
||||
return (
|
||||
<TableProperties component={component} section="style" />
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "image") {
|
||||
return (
|
||||
<ImageProperties component={component} section="style" />
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "divider") {
|
||||
return (
|
||||
<DividerProperties component={component} section="style" />
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "signature" || type === "stamp") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">표시 방식</Label>
|
||||
<Select value={component.objectFit || "contain"} onValueChange={(v) => update({ objectFit: v as ComponentConfig["objectFit"] })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="contain">비율 유지</SelectItem>
|
||||
<SelectItem value="cover">채우기</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">라벨 위치</Label>
|
||||
<Select value={component.labelPosition || "left"} onValueChange={(v) => update({ labelPosition: v as ComponentConfig["labelPosition"] })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
<SelectItem value="top">위</SelectItem>
|
||||
<SelectItem value="bottom">아래</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={component.showLabel !== false} onChange={(e) => update({ showLabel: e.target.checked })} className="h-4 w-4 rounded" />
|
||||
<Label className="text-xs">라벨 표시</Label>
|
||||
</div>
|
||||
{component.showLabel !== false && (
|
||||
<div>
|
||||
<Label className="text-xs">라벨 텍스트</Label>
|
||||
<Input value={component.labelText || (type === "stamp" ? "(인)" : "서명:")} onChange={(e) => update({ labelText: e.target.value })} className="h-9 text-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "pageNumber") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">페이지 번호 형식</Label>
|
||||
<Select value={component.pageNumberFormat || "number"} onValueChange={(v) => update({ pageNumberFormat: v as ComponentConfig["pageNumberFormat"] })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="number">1</SelectItem>
|
||||
<SelectItem value="numberTotal">1 / 3</SelectItem>
|
||||
<SelectItem value="koreanNumber">1 페이지</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">텍스트 정렬</Label>
|
||||
<Select value={component.textAlign || "center"} onValueChange={(v) => update({ textAlign: v })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">글꼴 굵기</Label>
|
||||
<Select value={component.fontWeight || "normal"} onValueChange={(v) => update({ fontWeight: v })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="bold">굵게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "calculation") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">숫자 형식</Label>
|
||||
<Select value={component.numberFormat || "currency"} onValueChange={(v) => update({ numberFormat: v as ComponentConfig["numberFormat"] })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
<SelectItem value="comma">콤마 (1,000)</SelectItem>
|
||||
<SelectItem value="currency">통화</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{component.numberFormat === "currency" && (
|
||||
<div>
|
||||
<Label className="text-xs">통화 단위</Label>
|
||||
<Input value={component.currencySuffix || "원"} onChange={(e) => update({ currencySuffix: e.target.value })} className="h-9 text-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">결과 색상</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" value={component.resultColor || "#2563eb"} onChange={(e) => update({ resultColor: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
|
||||
<Input value={component.resultColor || "#2563eb"} onChange={(e) => update({ resultColor: e.target.value })} className="h-9 text-sm flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">결과 크기</Label>
|
||||
<Input type="number" min={10} max={30} value={component.resultFontSize || 16} onChange={(e) => update({ resultFontSize: parseInt(e.target.value) || 16 })} className="h-9 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "barcode") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">바코드 색상</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" value={component.barcodeColor || "#000000"} onChange={(e) => update({ barcodeColor: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">배경 색상</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" value={component.barcodeBackground || "#ffffff"} onChange={(e) => update({ barcodeBackground: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={component.showBarcodeText !== false} onChange={(e) => update({ showBarcodeText: e.target.checked })} className="h-4 w-4 rounded" />
|
||||
<Label className="text-xs">하단 텍스트 표시</Label>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">여백 (px)</Label>
|
||||
<Input type="number" min={0} max={30} value={component.barcodeMargin ?? 10} onChange={(e) => update({ barcodeMargin: parseInt(e.target.value) || 0 })} className="h-9 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "checkbox") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">체크 색상</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" value={component.checkboxColor || "#2563eb"} onChange={(e) => update({ checkboxColor: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
|
||||
<Input value={component.checkboxColor || "#2563eb"} onChange={(e) => update({ checkboxColor: e.target.value })} className="h-9 text-sm flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">테두리 색상</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="color" value={component.checkboxBorderColor || "#6b7280"} onChange={(e) => update({ checkboxBorderColor: e.target.value })} className="w-9 h-9 rounded cursor-pointer border border-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">크기 (px)</Label>
|
||||
<Input type="number" min={12} max={40} value={component.checkboxSize || 18} onChange={(e) => update({ checkboxSize: parseInt(e.target.value) || 18 })} className="h-9 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">라벨 위치</Label>
|
||||
<Select value={component.checkboxLabelPosition || "right"} onValueChange={(v) => update({ checkboxLabelPosition: v as "left" | "right" })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "statusBadge") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">상태 매핑은 모달(더블클릭)에서 설정합니다.</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">텍스트 정렬</Label>
|
||||
<Select value={component.textAlign || "center"} onValueChange={(v) => update({ textAlign: v })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">글꼴 굵기</Label>
|
||||
<Select value={component.fontWeight || "bold"} onValueChange={(v) => update({ fontWeight: v })}>
|
||||
<SelectTrigger className="h-9 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="bold">굵게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ComponentStylePanel() {
|
||||
const {
|
||||
selectedComponentId,
|
||||
selectedComponentIds,
|
||||
components,
|
||||
updateComponent,
|
||||
bringToFront,
|
||||
sendToBack,
|
||||
bringForward,
|
||||
sendBackward,
|
||||
toggleLock,
|
||||
groupComponents,
|
||||
ungroupComponents,
|
||||
} = useReportDesigner();
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(["typeDesign"]));
|
||||
|
||||
const component =
|
||||
components.find((c) => c.id === selectedComponentId) ?? null;
|
||||
|
||||
if (!component) return null;
|
||||
|
||||
const typeLabel = TYPE_LABELS[component.type] ?? component.type;
|
||||
const update = (changes: Partial<ComponentConfig>) =>
|
||||
updateComponent(component.id, changes);
|
||||
|
||||
const toggleSection = (id: string) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const hasTypeDesign = component.type !== "card";
|
||||
const typeIcon = TYPE_ICONS[component.type] || <Square className="h-4 w-4" />;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 카드 전용: CardProperties 하위 아코디언 포함 */}
|
||||
{component.type === "card" && (
|
||||
<AccordionSection
|
||||
id="cardDesign"
|
||||
label="카드 디자인"
|
||||
icon={<CreditCard className="h-4 w-4" />}
|
||||
isExpanded={expandedSections.has("cardDesign")}
|
||||
onToggle={() => toggleSection("cardDesign")}
|
||||
>
|
||||
<CardProperties component={component} section="style" />
|
||||
</AccordionSection>
|
||||
)}
|
||||
|
||||
{/* 카드 외 모든 타입: 타입별 전용 디자인 */}
|
||||
{hasTypeDesign && (
|
||||
<AccordionSection
|
||||
id="typeDesign"
|
||||
label={`${typeLabel} 디자인`}
|
||||
icon={typeIcon}
|
||||
isExpanded={expandedSections.has("typeDesign")}
|
||||
onToggle={() => toggleSection("typeDesign")}
|
||||
>
|
||||
<TypeSpecificDesign component={component} update={update} />
|
||||
</AccordionSection>
|
||||
)}
|
||||
|
||||
{/* 배치 (X/Y/W/H) */}
|
||||
<AccordionSection
|
||||
id="layout"
|
||||
label="배치"
|
||||
icon={<Move className="h-4 w-4" />}
|
||||
isExpanded={expandedSections.has("layout")}
|
||||
onToggle={() => toggleSection("layout")}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{([
|
||||
{ label: "X", key: "x" as const, min: undefined },
|
||||
{ label: "Y", key: "y" as const, min: undefined },
|
||||
{ label: "너비", key: "width" as const, min: 50 },
|
||||
{ label: "높이", key: "height" as const, min: 30 },
|
||||
] as const).map(({ label, key, min }) => (
|
||||
<div key={key}>
|
||||
<Label className="text-xs">{label}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={Math.round(component[key] as number)}
|
||||
onChange={(e) =>
|
||||
update({ [key]: parseInt(e.target.value) || min || 0 })
|
||||
}
|
||||
className="h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{/* 잠금 */}
|
||||
<AccordionSection
|
||||
id="lock"
|
||||
label="잠금"
|
||||
icon={<Lock className="h-4 w-4" />}
|
||||
isExpanded={expandedSections.has("lock")}
|
||||
onToggle={() => toggleSection("lock")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant={component.locked ? "destructive" : "outline"}
|
||||
size="sm"
|
||||
className="w-full h-9 gap-2 text-sm"
|
||||
onClick={() => toggleLock()}
|
||||
>
|
||||
{component.locked ? (
|
||||
<>
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
잠금 해제
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Unlock className="h-3.5 w-3.5" />
|
||||
위치 잠금
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
잠금 시 드래그/리사이즈 불가
|
||||
</p>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{/* 레이어 */}
|
||||
<AccordionSection
|
||||
id="layer"
|
||||
label="레이어"
|
||||
icon={<Layers className="h-4 w-4" />}
|
||||
isExpanded={expandedSections.has("layer")}
|
||||
onToggle={() => toggleSection("layer")}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={() => bringToFront()} title="맨 앞으로">
|
||||
<ArrowUpToLine className="h-3.5 w-3.5" />
|
||||
맨 앞
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={() => sendToBack()} title="맨 뒤로">
|
||||
<ArrowDownToLine className="h-3.5 w-3.5" />
|
||||
맨 뒤
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={() => bringForward()} title="한 단계 앞으로">
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
앞으로
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={() => sendBackward()} title="한 단계 뒤로">
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
뒤로
|
||||
</Button>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
{/* 그룹 */}
|
||||
<AccordionSection
|
||||
id="group"
|
||||
label={`그룹${component.groupId ? " (그룹됨)" : ""}`}
|
||||
icon={<Group className="h-4 w-4" />}
|
||||
isExpanded={expandedSections.has("group")}
|
||||
onToggle={() => toggleSection("group")}
|
||||
>
|
||||
{component.groupId ? (
|
||||
<div className="space-y-2.5">
|
||||
<div className="rounded-md border border-purple-200 bg-purple-50 px-3 py-2">
|
||||
<p className="text-xs text-purple-700">
|
||||
그룹 내 컴포넌트를 드래그하면 함께 이동합니다.
|
||||
</p>
|
||||
<p className="text-[10px] text-purple-500 mt-1">
|
||||
같은 그룹: {components.filter((c) => c.groupId === component.groupId).length}개
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-8 gap-1.5 text-xs border-purple-300 text-purple-700 hover:bg-purple-50"
|
||||
onClick={() => ungroupComponents()}
|
||||
>
|
||||
<Ungroup className="h-3.5 w-3.5" />
|
||||
그룹 해제
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full h-8 gap-1.5 text-xs"
|
||||
onClick={() => groupComponents()}
|
||||
disabled={selectedComponentIds.length < 2}
|
||||
>
|
||||
<Group className="h-3.5 w-3.5" />
|
||||
선택 컴포넌트 그룹화
|
||||
</Button>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{selectedComponentIds.length < 2
|
||||
? "드래그 또는 Ctrl+클릭으로 2개 이상 선택 후 그룹화"
|
||||
: `${selectedComponentIds.length}개 선택됨 - 그룹화 가능`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</AccordionSection>
|
||||
|
||||
{/* 테두리 */}
|
||||
<AccordionSection
|
||||
id="border"
|
||||
label="테두리"
|
||||
icon={<Square className="h-4 w-4" />}
|
||||
isExpanded={expandedSections.has("border")}
|
||||
onToggle={() => toggleSection("border")}
|
||||
>
|
||||
<div>
|
||||
<Label className="text-xs">모양</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button onClick={() => update({ borderRadius: 0 })} className={`flex-1 h-9 rounded-md border text-xs font-medium transition-colors ${!component.borderRadius || component.borderRadius === 0 ? "border-blue-500 bg-blue-50 text-blue-700" : "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"}`}>사각형</button>
|
||||
<button onClick={() => update({ borderRadius: 16 })} className={`flex-1 h-9 rounded-md border text-xs font-medium transition-colors ${component.borderRadius && component.borderRadius > 0 ? "border-blue-500 bg-blue-50 text-blue-700" : "border-gray-200 bg-white text-gray-600 hover:bg-gray-50"}`}>둥글게</button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
<div className="h-6 shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Eye, EyeOff, HelpCircle, Trash2, Plus } from "lucide-react";
|
||||
import type { ComponentConfig, ConditionalRule } from "@/types/report";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
text: "텍스트",
|
||||
label: "레이블",
|
||||
table: "테이블",
|
||||
image: "이미지",
|
||||
divider: "구분선",
|
||||
signature: "서명",
|
||||
stamp: "도장",
|
||||
pageNumber: "페이지 번호",
|
||||
card: "카드",
|
||||
calculation: "계산",
|
||||
barcode: "바코드",
|
||||
checkbox: "체크박스",
|
||||
};
|
||||
|
||||
interface OperatorDef {
|
||||
value: ConditionalRule["operator"];
|
||||
symbol: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
group: "compare" | "text" | "exist";
|
||||
}
|
||||
|
||||
const OPERATORS: OperatorDef[] = [
|
||||
{ value: "eq", symbol: "=", label: "같을 때", summary: "가 '$V'일 때", group: "compare" },
|
||||
{ value: "ne", symbol: "≠", label: "다를 때", summary: "가 '$V'이(가) 아닐 때", group: "compare" },
|
||||
{ value: "gt", symbol: ">", label: "보다 클 때", summary: "이(가) $V보다 클 때", group: "compare" },
|
||||
{ value: "lt", symbol: "<", label: "보다 작을 때", summary: "이(가) $V보다 작을 때", group: "compare" },
|
||||
{ value: "gte", symbol: "≥", label: "이상일 때", summary: "이(가) $V 이상일 때", group: "compare" },
|
||||
{ value: "lte", symbol: "≤", label: "이하일 때", summary: "이(가) $V 이하일 때", group: "compare" },
|
||||
{ value: "contains", symbol: "⊃", label: "포함할 때", summary: "에 '$V'이(가) 포함될 때", group: "text" },
|
||||
{ value: "notEmpty", symbol: "✓", label: "값이 있을 때", summary: "에 값이 있을 때", group: "exist" },
|
||||
{ value: "empty", symbol: "∅", label: "값이 없을 때", summary: "에 값이 없을 때", group: "exist" },
|
||||
];
|
||||
|
||||
const OPERATOR_GROUPS: { key: OperatorDef["group"]; label: string }[] = [
|
||||
{ key: "compare", label: "비교" },
|
||||
{ key: "text", label: "텍스트" },
|
||||
{ key: "exist", label: "값 유무" },
|
||||
];
|
||||
|
||||
const NO_VALUE_OPERATORS = ["notEmpty", "empty"];
|
||||
|
||||
interface CardColumn {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
}
|
||||
|
||||
export interface CardColumnLabel {
|
||||
columnName: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
onConfigChange?: (updates: Partial<ComponentConfig>) => void;
|
||||
cardColumns?: CardColumn[];
|
||||
cardTableName?: string;
|
||||
cardColumnLabels?: CardColumnLabel[];
|
||||
/** conditionalRules 대신 사용할 키 (격자 모드 분리용) */
|
||||
rulesKey?: "conditionalRules" | "gridConditionalRules";
|
||||
/** conditionalRule 대신 사용할 키 (격자 모드 분리용) */
|
||||
ruleKey?: "conditionalRule" | "gridConditionalRule";
|
||||
}
|
||||
|
||||
const DEFAULT_RULE: ConditionalRule = {
|
||||
queryId: "",
|
||||
field: "",
|
||||
operator: "eq",
|
||||
value: "",
|
||||
action: "show",
|
||||
};
|
||||
|
||||
export function ConditionalProperties({
|
||||
component,
|
||||
onConfigChange,
|
||||
cardColumns,
|
||||
cardTableName,
|
||||
cardColumnLabels,
|
||||
rulesKey = "conditionalRules",
|
||||
ruleKey = "conditionalRule",
|
||||
}: Props) {
|
||||
const { updateComponent, queries, getQueryResult } = useReportDesigner();
|
||||
|
||||
const componentLabel = TYPE_LABELS[component.type] ?? component.type;
|
||||
const isCardMode = !!cardColumns;
|
||||
|
||||
const applyUpdate = useCallback(
|
||||
(updates: Partial<ComponentConfig>) => {
|
||||
if (onConfigChange) onConfigChange(updates);
|
||||
else updateComponent(component.id, updates);
|
||||
},
|
||||
[onConfigChange, updateComponent, component.id],
|
||||
);
|
||||
|
||||
/** 단일 rule → 배열로 정규화 (rulesKey/ruleKey 기반) */
|
||||
const rules: ConditionalRule[] = useMemo(() => {
|
||||
const rulesArr = component[rulesKey] as ConditionalRule[] | undefined;
|
||||
const singleRule = component[ruleKey] as ConditionalRule | undefined;
|
||||
if (rulesArr && rulesArr.length > 0) {
|
||||
return rulesArr;
|
||||
}
|
||||
if (singleRule) {
|
||||
return [singleRule];
|
||||
}
|
||||
return [];
|
||||
}, [component, rulesKey, ruleKey]);
|
||||
|
||||
const action = rules.length > 0 ? rules[0].action : "show";
|
||||
|
||||
const syncRules = useCallback(
|
||||
(newRules: ConditionalRule[]) => {
|
||||
applyUpdate({
|
||||
[rulesKey]: newRules,
|
||||
[ruleKey]: newRules.length > 0 ? newRules[0] : undefined,
|
||||
});
|
||||
},
|
||||
[applyUpdate, rulesKey, ruleKey],
|
||||
);
|
||||
|
||||
const getQueryFields = (queryId: string): string[] => {
|
||||
const result = getQueryResult(queryId);
|
||||
return result ? result.fields : [];
|
||||
};
|
||||
|
||||
const labelMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
cardColumnLabels?.forEach((cl) => map.set(cl.columnName, cl.label));
|
||||
return map;
|
||||
}, [cardColumnLabels]);
|
||||
|
||||
const addRule = useCallback(() => {
|
||||
const newRule: ConditionalRule = {
|
||||
...DEFAULT_RULE,
|
||||
queryId: isCardMode ? "__card__" : (queries[0]?.id || ""),
|
||||
action,
|
||||
};
|
||||
syncRules([...rules, newRule]);
|
||||
}, [rules, isCardMode, queries, action, syncRules]);
|
||||
|
||||
const updateRule = useCallback(
|
||||
(index: number, patch: Partial<ConditionalRule>) => {
|
||||
const updated = rules.map((r, i) => (i === index ? { ...r, ...patch } : r));
|
||||
syncRules(updated);
|
||||
},
|
||||
[rules, syncRules],
|
||||
);
|
||||
|
||||
const removeRule = useCallback(
|
||||
(index: number) => {
|
||||
const updated = rules.filter((_, i) => i !== index);
|
||||
syncRules(updated);
|
||||
},
|
||||
[rules, syncRules],
|
||||
);
|
||||
|
||||
const removeAllRules = useCallback(() => {
|
||||
syncRules([]);
|
||||
}, [syncRules]);
|
||||
|
||||
const updateAction = useCallback(
|
||||
(newAction: "show" | "hide") => {
|
||||
const updated = rules.map((r) => ({ ...r, action: newAction }));
|
||||
syncRules(updated);
|
||||
},
|
||||
[rules, syncRules],
|
||||
);
|
||||
|
||||
const getFieldsForRule = useCallback(
|
||||
(rule: ConditionalRule): string[] => {
|
||||
if (isCardMode) {
|
||||
return cardColumnLabels?.map((cl) => cl.columnName) ?? [];
|
||||
}
|
||||
return rule.queryId ? getQueryFields(rule.queryId) : [];
|
||||
},
|
||||
[isCardMode, cardColumnLabels],
|
||||
);
|
||||
|
||||
const getFieldDisplayName = useCallback(
|
||||
(fieldName: string): string => labelMap.get(fieldName) || fieldName,
|
||||
[labelMap],
|
||||
);
|
||||
|
||||
const summaryText = useMemo(() => {
|
||||
const validRules = rules.filter((r) => r.field && (isCardMode || r.queryId));
|
||||
if (validRules.length === 0) return null;
|
||||
|
||||
const parts = validRules.map((rule) => {
|
||||
const op = OPERATORS.find((o) => o.value === rule.operator);
|
||||
if (!op) return null;
|
||||
const fieldLabel = labelMap.get(rule.field) || rule.field;
|
||||
const condPart = op.summary.replace("$V", rule.value || "?");
|
||||
return `${fieldLabel}${condPart}`;
|
||||
}).filter(Boolean);
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
const conditionStr = parts.join(", ");
|
||||
const actionPart = action === "show"
|
||||
? `이 ${componentLabel}을(를) 보여줍니다`
|
||||
: `이 ${componentLabel}을(를) 숨깁니다`;
|
||||
|
||||
if (parts.length === 1) {
|
||||
return `${conditionStr} ${actionPart}`;
|
||||
}
|
||||
return `다음 조건을 모두 만족할 때 ${actionPart}: ${conditionStr}`;
|
||||
}, [rules, isCardMode, labelMap, componentLabel, action]);
|
||||
|
||||
const hasNoDataSource = isCardMode
|
||||
? !cardTableName || (cardColumnLabels?.length ?? 0) === 0
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-xl border border-gray-200 bg-gray-50">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-gray-800">
|
||||
{action === "hide" ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
표시 조건
|
||||
{rules.length > 0 && (
|
||||
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-blue-100 px-1.5 text-[10px] font-bold text-blue-700">
|
||||
{rules.length}
|
||||
</span>
|
||||
)}
|
||||
<TooltipProvider delayDuration={80}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button type="button" className="inline-flex">
|
||||
<HelpCircle className="h-3.5 w-3.5 text-gray-400 cursor-help" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="max-w-[300px] bg-gray-700 text-white text-xs leading-relaxed space-y-2 py-2.5"
|
||||
>
|
||||
<p>
|
||||
데이터 값에 따라 이 {componentLabel}을(를) 자동으로
|
||||
보이거나 숨길 수 있습니다.
|
||||
</p>
|
||||
<div className="border-t border-gray-500 pt-2 space-y-1">
|
||||
<p className="font-semibold text-gray-300">여러 조건 사용</p>
|
||||
<p>
|
||||
조건을 여러 개 추가하면 모든 조건을 동시에 만족해야
|
||||
결과가 적용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{rules.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={removeAllRules}
|
||||
className="h-6 px-2 text-xs text-red-500 hover:bg-red-50 gap-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
전체 삭제
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="px-4 pb-4 space-y-3">
|
||||
{hasNoDataSource ? (
|
||||
<p className="text-xs text-gray-400 text-center py-4">
|
||||
{!cardTableName
|
||||
? "데이터 연결 탭에서 테이블을 먼저 선택해주세요."
|
||||
: "레이아웃 구성 탭에서 데이터 항목을 먼저 추가해주세요."}
|
||||
</p>
|
||||
) : rules.length === 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={addRule}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
조건 추가
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{/* 결과 동작 (공통) */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-3 space-y-2.5">
|
||||
<p className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">결과</p>
|
||||
<Select
|
||||
value={action}
|
||||
onValueChange={(v) => updateAction(v as "show" | "hide")}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="show">
|
||||
조건을 만족하면 이 {componentLabel}을(를) 보여줍니다
|
||||
</SelectItem>
|
||||
<SelectItem value="hide">
|
||||
조건을 만족하면 이 {componentLabel}을(를) 숨깁니다
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 조건 목록 */}
|
||||
{rules.map((rule, index) => (
|
||||
<RuleEditor
|
||||
key={index}
|
||||
index={index}
|
||||
rule={rule}
|
||||
isCardMode={isCardMode}
|
||||
queries={queries}
|
||||
fields={getFieldsForRule(rule)}
|
||||
getFieldDisplayName={getFieldDisplayName}
|
||||
onUpdate={updateRule}
|
||||
onRemove={removeRule}
|
||||
showAndLabel={index > 0}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 조건 추가 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={addRule}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
표시 조건 추가
|
||||
</Button>
|
||||
|
||||
{/* 요약 */}
|
||||
{summaryText && (
|
||||
<div className="rounded-lg border border-blue-100 bg-blue-50/50 px-3 py-2.5">
|
||||
<p className="text-[11px] text-blue-500 mb-1 font-medium">요약</p>
|
||||
<p className="text-xs text-blue-700 leading-relaxed">{summaryText}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 개별 조건 편집기 */
|
||||
interface RuleEditorProps {
|
||||
index: number;
|
||||
rule: ConditionalRule;
|
||||
isCardMode: boolean;
|
||||
queries: { id: string; name: string }[];
|
||||
fields: string[];
|
||||
getFieldDisplayName: (fieldName: string) => string;
|
||||
onUpdate: (index: number, patch: Partial<ConditionalRule>) => void;
|
||||
onRemove: (index: number) => void;
|
||||
showAndLabel: boolean;
|
||||
}
|
||||
|
||||
function RuleEditor({
|
||||
index,
|
||||
rule,
|
||||
isCardMode,
|
||||
queries,
|
||||
fields,
|
||||
getFieldDisplayName,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
showAndLabel,
|
||||
}: RuleEditorProps) {
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(rule.operator);
|
||||
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{showAndLabel && (
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<div className="flex-1 border-t border-dashed border-gray-300" />
|
||||
<span className="text-[10px] font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full">동시 만족</span>
|
||||
<div className="flex-1 border-t border-dashed border-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-3 space-y-2.5 relative group">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[11px] font-semibold text-gray-400 uppercase tracking-wide">
|
||||
조건 {index + 1}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemove(index)}
|
||||
className="h-5 w-5 p-0 text-gray-400 hover:text-red-500 hover:bg-red-50 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isCardMode && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">데이터 출처</label>
|
||||
<Select
|
||||
value={rule.queryId || ""}
|
||||
onValueChange={(v) => onUpdate(index, { queryId: v, field: "" })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="데이터를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{queries.map((q) => (
|
||||
<SelectItem key={q.id} value={q.id}>
|
||||
{q.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">확인할 항목</label>
|
||||
<Select
|
||||
value={rule.field || ""}
|
||||
onValueChange={(v) => onUpdate(index, { field: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="항목을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fields.map((f) => (
|
||||
<SelectItem key={f} value={f} className="text-xs">
|
||||
{getFieldDisplayName(f)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className={needsValue ? "grid grid-cols-2 gap-2" : ""}>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">조건</label>
|
||||
<Select
|
||||
value={rule.operator}
|
||||
onValueChange={(v) =>
|
||||
onUpdate(index, { operator: v as ConditionalRule["operator"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
{rule.operator ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-flex h-4 w-4 items-center justify-center rounded bg-gray-100 text-[10px] font-bold text-gray-600">
|
||||
{OPERATORS.find((o) => o.value === rule.operator)?.symbol}
|
||||
</span>
|
||||
{OPERATORS.find((o) => o.value === rule.operator)?.label}
|
||||
</span>
|
||||
) : (
|
||||
<SelectValue placeholder="조건 선택" />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATOR_GROUPS.map((group, gi) => (
|
||||
<div key={group.key}>
|
||||
{gi > 0 && <div className="mx-2 my-1 border-t border-gray-100" />}
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-gray-400">
|
||||
{group.label}
|
||||
</div>
|
||||
{OPERATORS.filter((op) => op.group === group.key).map((op) => (
|
||||
<SelectItem key={op.value} value={op.value} className="text-xs">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-flex h-4 w-4 items-center justify-center rounded bg-gray-100 text-[10px] font-bold text-gray-600">
|
||||
{op.symbol}
|
||||
</span>
|
||||
{op.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{needsValue && (
|
||||
<div>
|
||||
<label className="text-xs font-medium text-gray-500">기준 값</label>
|
||||
<Input
|
||||
value={rule.value}
|
||||
onChange={(e) => onUpdate(index, { value: e.target.value })}
|
||||
placeholder="값 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DividerProperties.tsx — 구분선 컴포넌트 설정
|
||||
*
|
||||
* - section="data": 구분선은 데이터 바인딩 없으므로 null 반환
|
||||
* - section="style": StyleAccordion 패턴 (방향 & 선 스타일 + 색상)
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
function StyleAccordion({
|
||||
label,
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-100">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex h-10 w-full items-center justify-between px-3 transition-colors ${
|
||||
isOpen
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-bold">{label}</span>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 transition-transform ${isOpen ? "rotate-90" : "text-gray-400"}`}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DividerProperties({ component, section }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
|
||||
if (section === "data") return null;
|
||||
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["line"]));
|
||||
const toggleSection = (id: string) => {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Partial<ComponentConfig>) => updateComponent(component.id, updates),
|
||||
[component.id, updateComponent],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showStyle && (
|
||||
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200">
|
||||
{/* 방향 & 선 스타일 */}
|
||||
<StyleAccordion label="방향 & 선 스타일" isOpen={openSections.has("line")} onToggle={() => toggleSection("line")}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">방향</Label>
|
||||
<Select
|
||||
value={component.orientation || "horizontal"}
|
||||
onValueChange={(value) => {
|
||||
const isToVertical = value === "vertical";
|
||||
const currentWidth = component.width;
|
||||
const currentHeight = component.height;
|
||||
update({
|
||||
orientation: value as "horizontal" | "vertical",
|
||||
width: isToVertical ? 10 : currentWidth > 50 ? currentWidth : 300,
|
||||
height: isToVertical ? (currentWidth > 50 ? currentWidth : 300) : 10,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="horizontal">가로</SelectItem>
|
||||
<SelectItem value="vertical">세로</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">선 스타일</Label>
|
||||
<Select
|
||||
value={component.lineStyle || "solid"}
|
||||
onValueChange={(value) =>
|
||||
update({ lineStyle: value as "solid" | "dashed" | "dotted" | "double" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="solid">실선</SelectItem>
|
||||
<SelectItem value="dashed">파선</SelectItem>
|
||||
<SelectItem value="dotted">점선</SelectItem>
|
||||
<SelectItem value="double">이중선</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">선 두께</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0.5}
|
||||
max={20}
|
||||
step={0.5}
|
||||
value={component.lineWidth ?? 1}
|
||||
onChange={(e) => {
|
||||
const val = parseFloat(e.target.value);
|
||||
if (!isNaN(val) && val >= 0.5) {
|
||||
update({ lineWidth: val });
|
||||
}
|
||||
}}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 색상 */}
|
||||
<StyleAccordion label="색상" isOpen={openSections.has("color")} onToggle={() => toggleSection("color")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">선 색상</Label>
|
||||
<ColorInput
|
||||
value={component.lineColor || "#000000"}
|
||||
onChange={(v) => update({ lineColor: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ImageProperties.tsx — 이미지 컴포넌트 설정
|
||||
*
|
||||
* - section="data": 모달 내 ImageLayoutTabs (업로드 / 자르기 / 맞춤 / 캡션 / 표시 조건)
|
||||
* - section="style": 우측 패널 — 투명도, 모서리, 회전/반전, 캡션 스타일
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { ChevronRight, FlipHorizontal, FlipVertical } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
import { ImageLayoutTabs } from "../modals/ImageLayoutTabs";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
function StyleAccordion({
|
||||
label,
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-100">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex h-10 w-full items-center justify-between px-3 transition-colors ${
|
||||
isOpen
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-bold">{label}</span>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 transition-transform ${isOpen ? "rotate-90" : "text-gray-400"}`}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageProperties({ component, section }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["opacity"]));
|
||||
const toggleSection = (id: string) => {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const update = (updates: Partial<ComponentConfig>) => {
|
||||
updateComponent(component.id, updates);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showData && <ImageLayoutTabs component={component} />}
|
||||
|
||||
{showStyle && (
|
||||
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200">
|
||||
{/* 투명도 & 모서리 */}
|
||||
<StyleAccordion label="투명도 & 모서리" isOpen={openSections.has("opacity")} onToggle={() => toggleSection("opacity")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">
|
||||
투명도 ({Math.round((component.imageOpacity ?? 1) * 100)}%)
|
||||
</Label>
|
||||
<Slider
|
||||
value={[component.imageOpacity ?? 1]}
|
||||
onValueChange={([v]) => update({ imageOpacity: v })}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">
|
||||
모서리 ({component.imageBorderRadius ?? 0}px)
|
||||
</Label>
|
||||
<Slider
|
||||
value={[component.imageBorderRadius ?? 0]}
|
||||
onValueChange={([v]) => update({ imageBorderRadius: v })}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
/>
|
||||
<div className="mt-2 flex gap-1">
|
||||
{[0, 4, 8, 16, 50, 100].map((v) => (
|
||||
<Button
|
||||
key={v}
|
||||
type="button"
|
||||
variant={component.imageBorderRadius === v ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => update({ imageBorderRadius: v })}
|
||||
className="h-6 flex-1 px-1 text-[10px]"
|
||||
>
|
||||
{v === 0 ? "직각" : v === 100 ? "원형" : `${v}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 회전 & 반전 */}
|
||||
<StyleAccordion label="회전 & 반전" isOpen={openSections.has("transform")} onToggle={() => toggleSection("transform")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">
|
||||
회전 ({component.imageRotation || 0}°)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[component.imageRotation || 0]}
|
||||
onValueChange={([v]) => update({ imageRotation: v })}
|
||||
min={0}
|
||||
max={360}
|
||||
step={1}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.imageRotation || 0}
|
||||
onChange={(e) => update({ imageRotation: parseInt(e.target.value) || 0 })}
|
||||
className="h-7 w-14 text-center text-[10px]"
|
||||
min={0}
|
||||
max={360}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={component.imageFlipH ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => update({ imageFlipH: !component.imageFlipH })}
|
||||
className="h-8 flex-1 text-xs"
|
||||
>
|
||||
<FlipHorizontal className="mr-1 h-3.5 w-3.5" />좌우
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={component.imageFlipV ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => update({ imageFlipV: !component.imageFlipV })}
|
||||
className="h-8 flex-1 text-xs"
|
||||
>
|
||||
<FlipVertical className="mr-1 h-3.5 w-3.5" />상하
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 캡션 스타일 (캡션 텍스트가 있을 때만) */}
|
||||
{component.imageCaption && (
|
||||
<StyleAccordion label="캡션 스타일" isOpen={openSections.has("caption")} onToggle={() => toggleSection("caption")}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">글자 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={component.imageCaptionFontSize || 12}
|
||||
onChange={(e) => update({ imageCaptionFontSize: parseInt(e.target.value) || 12 })}
|
||||
className="h-9 text-xs"
|
||||
min={8}
|
||||
max={32}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">색상</Label>
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={component.imageCaptionColor || "#666666"}
|
||||
onChange={(e) => update({ imageCaptionColor: e.target.value })}
|
||||
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0"
|
||||
/>
|
||||
<Input
|
||||
value={component.imageCaptionColor || "#666666"}
|
||||
onChange={(e) => update({ imageCaptionColor: e.target.value })}
|
||||
className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ComponentConfig } from "@/types/report";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
|
||||
const FONT_FAMILIES = [
|
||||
"Malgun Gothic",
|
||||
"NanumGothic",
|
||||
"NanumMyeongjo",
|
||||
"굴림",
|
||||
"돋움",
|
||||
"바탕",
|
||||
"Times New Roman",
|
||||
"Arial",
|
||||
];
|
||||
|
||||
interface Props {
|
||||
componentIds: string[];
|
||||
components: ComponentConfig[];
|
||||
}
|
||||
|
||||
type EditableField =
|
||||
| "fontSize"
|
||||
| "fontFamily"
|
||||
| "fontColor"
|
||||
| "fontWeight"
|
||||
| "textAlign"
|
||||
| "backgroundColor"
|
||||
| "borderWidth"
|
||||
| "borderColor";
|
||||
|
||||
function getCommonValue<T>(components: ComponentConfig[], field: EditableField): T | "mixed" {
|
||||
const values = components.map((c) => c[field as keyof ComponentConfig]);
|
||||
const first = values[0];
|
||||
return values.every((v) => v === first) ? (first as T) : "mixed";
|
||||
}
|
||||
|
||||
export function MultiSelectProperties({ componentIds, components }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
|
||||
const selected = components.filter((c) => componentIds.includes(c.id));
|
||||
const count = selected.length;
|
||||
|
||||
const applyToAll = (patch: Partial<ComponentConfig>) => {
|
||||
componentIds.forEach((id) => updateComponent(id, patch));
|
||||
};
|
||||
|
||||
const commonFontSize = getCommonValue<number>(selected, "fontSize");
|
||||
const commonFontFamily = getCommonValue<string>(selected, "fontFamily");
|
||||
const commonFontColor = getCommonValue<string>(selected, "fontColor");
|
||||
const commonFontWeight = getCommonValue<string>(selected, "fontWeight");
|
||||
const commonTextAlign = getCommonValue<string>(selected, "textAlign");
|
||||
const commonBgColor = getCommonValue<string>(selected, "backgroundColor");
|
||||
const commonBorderWidth = getCommonValue<number>(selected, "borderWidth");
|
||||
const commonBorderColor = getCommonValue<string>(selected, "borderColor");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">{count}개 컴포넌트 선택됨</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-gray-500">공통 속성을 변경하면 선택된 모든 컴포넌트에 적용됩니다.</p>
|
||||
|
||||
{/* 글꼴 크기 */}
|
||||
<div>
|
||||
<Label className="text-xs">글꼴 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={commonFontSize === "mixed" ? "" : (commonFontSize as number) || 13}
|
||||
placeholder={commonFontSize === "mixed" ? "혼합" : undefined}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (val > 0) applyToAll({ fontSize: val });
|
||||
}}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 폰트 패밀리 */}
|
||||
<div>
|
||||
<Label className="text-xs">폰트 패밀리</Label>
|
||||
<Select
|
||||
value={commonFontFamily === "mixed" ? "" : (commonFontFamily as string) || "Malgun Gothic"}
|
||||
onValueChange={(value) => applyToAll({ fontFamily: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder={commonFontFamily === "mixed" ? "혼합" : "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_FAMILIES.map((f) => (
|
||||
<SelectItem key={f} value={f} style={{ fontFamily: f }}>
|
||||
{f}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 글꼴 색상 */}
|
||||
<div>
|
||||
<Label className="text-xs">글꼴 색상</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={commonFontColor === "mixed" ? "#000000" : (commonFontColor as string) || "#000000"}
|
||||
onChange={(e) => applyToAll({ fontColor: e.target.value })}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={commonFontColor === "mixed" ? "" : (commonFontColor as string) || "#000000"}
|
||||
placeholder={commonFontColor === "mixed" ? "혼합" : undefined}
|
||||
onChange={(e) => applyToAll({ fontColor: e.target.value })}
|
||||
className="h-8 flex-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 글꼴 굵기 */}
|
||||
<div>
|
||||
<Label className="text-xs">글꼴 굵기</Label>
|
||||
<Select
|
||||
value={commonFontWeight === "mixed" ? "" : (commonFontWeight as string) || "normal"}
|
||||
onValueChange={(value) => applyToAll({ fontWeight: value as "normal" | "bold" })}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder={commonFontWeight === "mixed" ? "혼합" : "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">보통</SelectItem>
|
||||
<SelectItem value="bold">굵게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 텍스트 정렬 */}
|
||||
<div>
|
||||
<Label className="text-xs">텍스트 정렬</Label>
|
||||
<Select
|
||||
value={commonTextAlign === "mixed" ? "" : (commonTextAlign as string) || "left"}
|
||||
onValueChange={(value) => applyToAll({ textAlign: value as "left" | "center" | "right" })}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder={commonTextAlign === "mixed" ? "혼합" : "선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="center">가운데</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 배경 색상 */}
|
||||
<div>
|
||||
<Label className="text-xs">배경 색상</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={commonBgColor === "mixed" ? "#ffffff" : (commonBgColor as string) || "#ffffff"}
|
||||
onChange={(e) => applyToAll({ backgroundColor: e.target.value })}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={commonBgColor === "mixed" ? "" : (commonBgColor as string) || "#ffffff"}
|
||||
placeholder={commonBgColor === "mixed" ? "혼합" : undefined}
|
||||
onChange={(e) => applyToAll({ backgroundColor: e.target.value })}
|
||||
className="h-8 flex-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테두리 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">테두리 두께</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="10"
|
||||
value={commonBorderWidth === "mixed" ? "" : (commonBorderWidth as number) || 0}
|
||||
placeholder={commonBorderWidth === "mixed" ? "혼합" : undefined}
|
||||
onChange={(e) => applyToAll({ borderWidth: parseInt(e.target.value) || 0 })}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">테두리 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={commonBorderColor === "mixed" ? "#cccccc" : (commonBorderColor as string) || "#cccccc"}
|
||||
onChange={(e) => applyToAll({ borderColor: e.target.value })}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Hash } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
export function PageNumberProperties({ component }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-purple-200 bg-purple-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-purple-700">
|
||||
<Hash className="h-4 w-4" />
|
||||
페이지 번호 설정
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">표시 형식</Label>
|
||||
<Select
|
||||
value={component.pageNumberFormat || "number"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, {
|
||||
pageNumberFormat: value as "number" | "numberTotal" | "koreanNumber",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="number">숫자만 (1, 2, 3...)</SelectItem>
|
||||
<SelectItem value="numberTotal">현재/전체 (1 / 3)</SelectItem>
|
||||
<SelectItem value="koreanNumber">한글 (1 페이지)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Loader2, PenLine, Upload } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { SignatureGenerator } from "../SignatureGenerator";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
export function SignatureProperties({ component, section }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
const [signatureMethod, setSignatureMethod] = useState<"upload" | "generate">("upload");
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast({ title: "오류", description: "이미지 파일만 업로드 가능합니다.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast({ title: "오류", description: "파일 크기는 10MB 이하여야 합니다.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploadingImage(true);
|
||||
const result = await reportApi.uploadImage(file);
|
||||
if (result.success) {
|
||||
updateComponent(component.id, { imageUrl: result.data.fileUrl });
|
||||
toast({ title: "성공", description: "이미지가 업로드되었습니다." });
|
||||
}
|
||||
} catch {
|
||||
toast({ title: "오류", description: "이미지 업로드 중 오류가 발생했습니다.", variant: "destructive" });
|
||||
} finally {
|
||||
setUploadingImage(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 서명란 스타일 — 우측 패널(section="style")에서 표시 */}
|
||||
{showStyle && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-indigo-200 bg-indigo-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-indigo-700">
|
||||
<PenLine className="h-4 w-4" />
|
||||
{component.type === "signature" ? "서명란 스타일" : "도장란 스타일"}
|
||||
</div>
|
||||
|
||||
{/* 맞춤 방식 */}
|
||||
<div>
|
||||
<Label className="text-xs">맞춤 방식</Label>
|
||||
<Select
|
||||
value={component.objectFit || "contain"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, { objectFit: value as "contain" | "cover" | "fill" | "none" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="contain">포함 (비율 유지)</SelectItem>
|
||||
<SelectItem value="cover">채우기 (잘림)</SelectItem>
|
||||
<SelectItem value="fill">늘리기</SelectItem>
|
||||
<SelectItem value="none">원본 크기</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 레이블 표시 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="showLabel"
|
||||
checked={component.showLabel !== false}
|
||||
onChange={(e) => updateComponent(component.id, { showLabel: e.target.checked })}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="showLabel" className="text-xs">
|
||||
레이블 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 레이블 텍스트 */}
|
||||
{component.showLabel !== false && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">레이블 텍스트</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={component.labelText || (component.type === "signature" ? "서명:" : "(인)")}
|
||||
onChange={(e) => updateComponent(component.id, { labelText: e.target.value })}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 레이블 위치 (서명란만) */}
|
||||
{component.type === "signature" && (
|
||||
<div>
|
||||
<Label className="text-xs">레이블 위치</Label>
|
||||
<Select
|
||||
value={component.labelPosition || "left"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(component.id, {
|
||||
labelPosition: value as "top" | "left" | "bottom" | "right",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="top">위</SelectItem>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="bottom">아래</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 서명 입력 — 모달(section="data")에서 표시 */}
|
||||
{showData && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-indigo-200 bg-indigo-50/50 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-indigo-700">
|
||||
<PenLine className="h-4 w-4" />
|
||||
{component.type === "signature" ? "서명 입력" : "도장 이미지"}
|
||||
</div>
|
||||
|
||||
{component.type === "signature" ? (
|
||||
<>
|
||||
{/* 서명 방식 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">서명 방식</Label>
|
||||
<Select
|
||||
value={signatureMethod}
|
||||
onValueChange={(value: "upload" | "generate") => setSignatureMethod(value)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="upload">이미지 업로드</SelectItem>
|
||||
<SelectItem value="generate">서명 만들기</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 이미지 업로드 */}
|
||||
{signatureMethod === "upload" && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingImage}
|
||||
className="flex-1"
|
||||
>
|
||||
{uploadingImage ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
업로드 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{component.imageUrl ? "파일 변경" : "파일 선택"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">JPG, PNG, GIF, WEBP (최대 10MB)</p>
|
||||
{component.imageUrl && !component.imageUrl.startsWith("data:") && (
|
||||
<p className="truncate text-xs text-indigo-600">현재: {component.imageUrl}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 서명 만들기 */}
|
||||
{signatureMethod === "generate" && (
|
||||
<div className="mt-3">
|
||||
<SignatureGenerator
|
||||
onSignatureSelect={(dataUrl) => {
|
||||
updateComponent(component.id, { imageUrl: dataUrl });
|
||||
toast({ title: "성공", description: "서명이 적용되었습니다." });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// 도장란
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">도장 이미지</Label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
disabled={uploadingImage}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingImage}
|
||||
className="flex-1"
|
||||
>
|
||||
{uploadingImage ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
업로드 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{component.imageUrl ? "파일 변경" : "파일 선택"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">JPG, PNG, GIF, WEBP (최대 10MB)</p>
|
||||
{component.imageUrl && !component.imageUrl.startsWith("data:") && (
|
||||
<p className="mt-2 truncate text-xs text-indigo-600">현재: {component.imageUrl}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이름 입력 (도장란만) */}
|
||||
<div>
|
||||
<Label className="text-xs">이름</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={component.personName || ""}
|
||||
onChange={(e) => updateComponent(component.id, { personName: e.target.value })}
|
||||
placeholder="예: 홍길동"
|
||||
className="h-9"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">도장 옆에 표시될 이름</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TableProperties.tsx — 테이블 컴포넌트 설정
|
||||
*
|
||||
* - section="data": TableLayoutTabs (컬럼 구성 / 요약 설정 탭)
|
||||
* - section="style": StyleAccordion 패턴 (프리셋 + 헤더 / 셀 / 테두리)
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { TableLayoutTabs } from "../modals/TableLayoutTabs";
|
||||
import type { ComponentConfig, GridCell } from "@/types/report";
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
// ─── 프리셋 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const TABLE_STYLE_PRESETS = {
|
||||
default: {
|
||||
headerBackgroundColor: "#f3f4f6",
|
||||
headerTextColor: "#111827",
|
||||
showBorder: true,
|
||||
rowHeight: 32,
|
||||
},
|
||||
dark: {
|
||||
headerBackgroundColor: "#1e293b",
|
||||
headerTextColor: "#ffffff",
|
||||
showBorder: true,
|
||||
rowHeight: 32,
|
||||
},
|
||||
blue: {
|
||||
headerBackgroundColor: "#1d4ed8",
|
||||
headerTextColor: "#ffffff",
|
||||
showBorder: true,
|
||||
rowHeight: 32,
|
||||
},
|
||||
minimal: {
|
||||
headerBackgroundColor: "#f8fafc",
|
||||
headerTextColor: "#374151",
|
||||
showBorder: false,
|
||||
rowHeight: 28,
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ─── StyleAccordion ────────────────────────────────────────────────────────────
|
||||
|
||||
function StyleAccordion({
|
||||
label,
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-100">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex h-10 w-full items-center justify-between px-3 transition-colors ${
|
||||
isOpen
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-bold">{label}</span>
|
||||
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${isOpen ? "rotate-90" : "text-gray-400"}`} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ColorInput ────────────────────────────────────────────────────────────────
|
||||
|
||||
function ColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 컴포넌트 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function TableProperties({ component, section }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["preset"]));
|
||||
const toggleSection = (id: string) => {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Partial<ComponentConfig>) => updateComponent(component.id, updates),
|
||||
[component.id, updateComponent],
|
||||
);
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(key: keyof typeof TABLE_STYLE_PRESETS) => {
|
||||
const preset = TABLE_STYLE_PRESETS[key];
|
||||
const updates: Partial<ComponentConfig> = { ...preset };
|
||||
|
||||
if (component.gridMode && component.gridCells) {
|
||||
const headerRows = component.gridHeaderRows ?? 1;
|
||||
const headerCols = component.gridHeaderCols ?? 1;
|
||||
const newCells = component.gridCells.map((cell: GridCell) => {
|
||||
if (cell.merged) return cell;
|
||||
const isHeader = cell.row < headerRows || cell.col < headerCols;
|
||||
if (!isHeader) return cell;
|
||||
return {
|
||||
...cell,
|
||||
backgroundColor: preset.headerBackgroundColor,
|
||||
textColor: preset.headerTextColor,
|
||||
};
|
||||
});
|
||||
updates.gridCells = newCells;
|
||||
}
|
||||
|
||||
update(updates);
|
||||
},
|
||||
[update, component.gridMode, component.gridCells, component.gridHeaderRows, component.gridHeaderCols],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showData && <TableLayoutTabs component={component} />}
|
||||
|
||||
{showStyle && (
|
||||
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200">
|
||||
{/* 프리셋 */}
|
||||
<StyleAccordion label="프리셋" isOpen={openSections.has("preset")} onToggle={() => toggleSection("preset")}>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(["default", "dark", "blue", "minimal"] as const).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => applyPreset(key)}
|
||||
className="flex flex-col items-center gap-1.5 rounded-lg border border-gray-200 bg-white p-2 text-[10px] font-medium text-gray-700 transition-all hover:border-blue-300 hover:bg-blue-50/50"
|
||||
>
|
||||
<div
|
||||
className="h-5 w-full rounded"
|
||||
style={{
|
||||
backgroundColor: TABLE_STYLE_PRESETS[key].headerBackgroundColor,
|
||||
border: TABLE_STYLE_PRESETS[key].showBorder ? "1px solid #d1d5db" : "none",
|
||||
}}
|
||||
/>
|
||||
{{ default: "기본", dark: "다크", blue: "블루", minimal: "미니멀" }[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 헤더 스타일 */}
|
||||
<StyleAccordion label="헤더 스타일" isOpen={openSections.has("header")} onToggle={() => toggleSection("header")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">배경색</Label>
|
||||
<ColorInput
|
||||
value={component.headerBackgroundColor || "#f3f4f6"}
|
||||
onChange={(v) => update({ headerBackgroundColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">텍스트 색상</Label>
|
||||
<ColorInput
|
||||
value={component.headerTextColor || "#111827"}
|
||||
onChange={(v) => update({ headerTextColor: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 셀 스타일 */}
|
||||
<StyleAccordion label="셀 스타일" isOpen={openSections.has("cell")} onToggle={() => toggleSection("cell")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">행 높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={20}
|
||||
max={100}
|
||||
value={component.rowHeight || 32}
|
||||
onChange={(e) => update({ rowHeight: parseInt(e.target.value) || 32 })}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">글자 크기</Label>
|
||||
<Select
|
||||
value={String(component.fontSize || 12)}
|
||||
onValueChange={(v) => update({ fontSize: parseInt(v) })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10px</SelectItem>
|
||||
<SelectItem value="11">11px</SelectItem>
|
||||
<SelectItem value="12">12px (기본)</SelectItem>
|
||||
<SelectItem value="13">13px</SelectItem>
|
||||
<SelectItem value="14">14px</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 테두리 */}
|
||||
<StyleAccordion label="테두리" isOpen={openSections.has("border")} onToggle={() => toggleSection("border")}>
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white px-3 py-2">
|
||||
<Label className="text-xs text-gray-500">테두리 표시</Label>
|
||||
<Switch
|
||||
checked={component.showBorder !== false}
|
||||
onCheckedChange={(checked) => update({ showBorder: checked })}
|
||||
/>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* TextProperties.tsx — 텍스트/레이블 컴포넌트 설정
|
||||
*
|
||||
* - section="data": TextLayoutTabs (데이터 바인딩 / 텍스트 서식 / 표시 조건)
|
||||
* - section="style": StyleAccordion 패턴 (프리셋 + 폰트 + 색상 + 정렬 + 테두리)
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { ChevronRight, Bold, Italic, Underline, Strikethrough, AlignLeft, AlignCenter, AlignRight } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { TextLayoutTabs } from "../modals/TextLayoutTabs";
|
||||
import type { ComponentConfig } from "@/types/report";
|
||||
|
||||
const FONT_FAMILIES = [
|
||||
"Malgun Gothic",
|
||||
"NanumGothic",
|
||||
"NanumMyeongjo",
|
||||
"굴림",
|
||||
"돋움",
|
||||
"바탕",
|
||||
"Times New Roman",
|
||||
"Arial",
|
||||
];
|
||||
|
||||
const TEXT_STYLE_PRESETS = {
|
||||
title: {
|
||||
label: "제목",
|
||||
fontSize: 24,
|
||||
fontWeight: "bold",
|
||||
fontColor: "#111827",
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
subtitle: {
|
||||
label: "부제목",
|
||||
fontSize: 16,
|
||||
fontWeight: "600",
|
||||
fontColor: "#374151",
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
body: {
|
||||
label: "본문",
|
||||
fontSize: 12,
|
||||
fontWeight: "normal",
|
||||
fontColor: "#374151",
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
caption: {
|
||||
label: "캡션",
|
||||
fontSize: 10,
|
||||
fontWeight: "normal",
|
||||
fontColor: "#6b7280",
|
||||
fontStyle: "italic" as const,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
header: {
|
||||
label: "헤더",
|
||||
fontSize: 14,
|
||||
fontWeight: "bold",
|
||||
fontColor: "#1e40af",
|
||||
lineHeight: 1.5,
|
||||
backgroundColor: "#eff6ff",
|
||||
borderWidth: 1,
|
||||
borderColor: "#bfdbfe",
|
||||
padding: 8,
|
||||
},
|
||||
footer: {
|
||||
label: "푸터",
|
||||
fontSize: 9,
|
||||
fontWeight: "normal",
|
||||
fontColor: "#9ca3af",
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
/** 우측 패널: "style" | 모달: "data" | 미전달: 전체 표시 (하위 호환) */
|
||||
section?: "style" | "data";
|
||||
}
|
||||
|
||||
function StyleAccordion({
|
||||
label,
|
||||
isOpen,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-gray-100">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={`flex h-10 w-full items-center justify-between px-3 transition-colors ${
|
||||
isOpen
|
||||
? "bg-linear-to-r from-blue-600 to-indigo-600 text-white shadow-sm"
|
||||
: "bg-white text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-bold">{label}</span>
|
||||
<ChevronRight className={`h-3.5 w-3.5 transition-transform ${isOpen ? "rotate-90" : "text-gray-400"}`} />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-t border-blue-100 bg-linear-to-b from-blue-50/30 to-white p-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-2 py-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 p-0"
|
||||
/>
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="h-7 border-0 bg-transparent px-1 font-mono text-xs shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleBtn({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-md border transition-colors ${
|
||||
active
|
||||
? "border-blue-300 bg-blue-50 text-blue-700"
|
||||
: "border-gray-200 bg-white text-gray-500 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextProperties({ component, section }: Props) {
|
||||
const { updateComponent } = useReportDesigner();
|
||||
|
||||
const showStyle = !section || section === "style";
|
||||
const showData = !section || section === "data";
|
||||
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(new Set(["preset"]));
|
||||
const toggleSection = (id: string) => {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const update = useCallback(
|
||||
(updates: Partial<ComponentConfig>) => updateComponent(component.id, updates),
|
||||
[component.id, updateComponent],
|
||||
);
|
||||
|
||||
const applyPreset = useCallback(
|
||||
(key: keyof typeof TEXT_STYLE_PRESETS) => {
|
||||
const { label, ...values } = TEXT_STYLE_PRESETS[key];
|
||||
update({ ...values, textPreset: key });
|
||||
},
|
||||
[update],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 모달(section="data")에서 표시: TextLayoutTabs 3탭 구조 */}
|
||||
{showData && <TextLayoutTabs component={component} />}
|
||||
|
||||
{/* 우측 패널(section="style")에서 표시: StyleAccordion 패턴 */}
|
||||
{showStyle && (
|
||||
<div className="mt-2 overflow-hidden rounded-lg border border-gray-200">
|
||||
{/* 프리셋 */}
|
||||
<StyleAccordion label="프리셋" isOpen={openSections.has("preset")} onToggle={() => toggleSection("preset")}>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.entries(TEXT_STYLE_PRESETS) as [keyof typeof TEXT_STYLE_PRESETS, typeof TEXT_STYLE_PRESETS[keyof typeof TEXT_STYLE_PRESETS]][]).map(
|
||||
([key, preset]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => applyPreset(key)}
|
||||
className={`flex flex-col items-center gap-1 rounded-lg border p-2 text-[10px] font-medium transition-all hover:border-blue-300 hover:bg-blue-50/50 ${
|
||||
component.textPreset === key
|
||||
? "border-blue-400 bg-blue-50 text-blue-700"
|
||||
: "border-gray-200 bg-white text-gray-700"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="flex w-full items-center justify-center rounded px-1 py-0.5"
|
||||
style={{
|
||||
fontSize: `${Math.min(preset.fontSize, 16)}px`,
|
||||
fontWeight: preset.fontWeight,
|
||||
color: preset.fontColor,
|
||||
lineHeight: "1.2",
|
||||
backgroundColor: ("backgroundColor" in preset) ? (preset as Record<string, unknown>).backgroundColor as string : undefined,
|
||||
}}
|
||||
>
|
||||
Aa
|
||||
</span>
|
||||
{preset.label}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 폰트 */}
|
||||
<StyleAccordion label="폰트" isOpen={openSections.has("font")} onToggle={() => toggleSection("font")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">폰트 패밀리</Label>
|
||||
<Select
|
||||
value={component.fontFamily || "Malgun Gothic"}
|
||||
onValueChange={(v) => update({ fontFamily: v })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_FAMILIES.map((f) => (
|
||||
<SelectItem key={f} value={f} style={{ fontFamily: f }}>{f}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={6}
|
||||
max={120}
|
||||
value={component.fontSize || 12}
|
||||
onChange={(e) => update({ fontSize: parseInt(e.target.value) || 12 })}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">줄 간격</Label>
|
||||
<Select
|
||||
value={String(component.lineHeight || 1.5)}
|
||||
onValueChange={(v) => update({ lineHeight: parseFloat(v) })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1.0</SelectItem>
|
||||
<SelectItem value="1.2">1.2</SelectItem>
|
||||
<SelectItem value="1.5">1.5</SelectItem>
|
||||
<SelectItem value="1.8">1.8</SelectItem>
|
||||
<SelectItem value="2">2.0</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">스타일</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<ToggleBtn
|
||||
active={component.fontWeight === "bold" || component.fontWeight === "700"}
|
||||
onClick={() => update({ fontWeight: component.fontWeight === "bold" || component.fontWeight === "700" ? "normal" : "bold" })}
|
||||
title="굵게"
|
||||
>
|
||||
<Bold className="h-3.5 w-3.5" />
|
||||
</ToggleBtn>
|
||||
<ToggleBtn
|
||||
active={component.fontStyle === "italic"}
|
||||
onClick={() => update({ fontStyle: component.fontStyle === "italic" ? "normal" : "italic" })}
|
||||
title="기울임"
|
||||
>
|
||||
<Italic className="h-3.5 w-3.5" />
|
||||
</ToggleBtn>
|
||||
<ToggleBtn
|
||||
active={component.textDecoration === "underline"}
|
||||
onClick={() => update({ textDecoration: component.textDecoration === "underline" ? "none" : "underline" })}
|
||||
title="밑줄"
|
||||
>
|
||||
<Underline className="h-3.5 w-3.5" />
|
||||
</ToggleBtn>
|
||||
<ToggleBtn
|
||||
active={component.textDecoration === "line-through"}
|
||||
onClick={() => update({ textDecoration: component.textDecoration === "line-through" ? "none" : "line-through" })}
|
||||
title="취소선"
|
||||
>
|
||||
<Strikethrough className="h-3.5 w-3.5" />
|
||||
</ToggleBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 색상 */}
|
||||
<StyleAccordion label="색상" isOpen={openSections.has("color")} onToggle={() => toggleSection("color")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">글자 색상</Label>
|
||||
<ColorInput
|
||||
value={component.fontColor || "#000000"}
|
||||
onChange={(v) => update({ fontColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">배경색</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<ColorInput
|
||||
value={component.backgroundColor || "#ffffff"}
|
||||
onChange={(v) => update({ backgroundColor: v })}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-xs text-gray-400"
|
||||
onClick={() => update({ backgroundColor: undefined })}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 정렬 & 간격 */}
|
||||
<StyleAccordion label="정렬 & 간격" isOpen={openSections.has("align")} onToggle={() => toggleSection("align")}>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">수평 정렬</Label>
|
||||
<div className="flex gap-1.5">
|
||||
<ToggleBtn active={(component.textAlign || "left") === "left"} onClick={() => update({ textAlign: "left" })} title="왼쪽">
|
||||
<AlignLeft className="h-3.5 w-3.5" />
|
||||
</ToggleBtn>
|
||||
<ToggleBtn active={component.textAlign === "center"} onClick={() => update({ textAlign: "center" })} title="가운데">
|
||||
<AlignCenter className="h-3.5 w-3.5" />
|
||||
</ToggleBtn>
|
||||
<ToggleBtn active={component.textAlign === "right"} onClick={() => update({ textAlign: "right" })} title="오른쪽">
|
||||
<AlignRight className="h-3.5 w-3.5" />
|
||||
</ToggleBtn>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">자간 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={-5}
|
||||
max={20}
|
||||
step={0.5}
|
||||
value={component.letterSpacing ?? 0}
|
||||
onChange={(e) => update({ letterSpacing: parseFloat(e.target.value) || 0 })}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">안쪽 여백</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={50}
|
||||
value={component.padding ?? 0}
|
||||
onChange={(e) => update({ padding: parseInt(e.target.value) || 0 })}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
{/* 테두리 */}
|
||||
<StyleAccordion label="테두리" isOpen={openSections.has("border")} onToggle={() => toggleSection("border")}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">두께</Label>
|
||||
<Select
|
||||
value={String(component.borderWidth ?? 0)}
|
||||
onValueChange={(v) => update({ borderWidth: parseInt(v) })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">없음</SelectItem>
|
||||
<SelectItem value="1">1px</SelectItem>
|
||||
<SelectItem value="2">2px</SelectItem>
|
||||
<SelectItem value="3">3px</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">모서리</Label>
|
||||
<Select
|
||||
value={String(component.borderRadius ?? 0)}
|
||||
onValueChange={(v) => update({ borderRadius: parseInt(v) })}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">없음</SelectItem>
|
||||
<SelectItem value="4">4px</SelectItem>
|
||||
<SelectItem value="8">8px</SelectItem>
|
||||
<SelectItem value="12">12px</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{(component.borderWidth ?? 0) > 0 && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs text-gray-500">색상</Label>
|
||||
<ColorInput
|
||||
value={component.borderColor || "#d1d5db"}
|
||||
onChange={(v) => update({ borderColor: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyleAccordion>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Database, Columns3, FunctionSquare, Play, Plus, Trash2, Code, Loader2 } from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import type { ComponentConfig, VisualQuery, VisualQueryFormulaColumn } from "@/types/report";
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface SchemaTable {
|
||||
table_name: string;
|
||||
table_type: string;
|
||||
}
|
||||
|
||||
interface SchemaColumn {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
component: ComponentConfig;
|
||||
}
|
||||
|
||||
// ─── 컴포넌트 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function VisualQueryBuilder({ component }: Props) {
|
||||
const { updateComponent, setQueryResult } = useReportDesigner();
|
||||
|
||||
const vq: VisualQuery = component.visualQuery ?? {
|
||||
tableName: "",
|
||||
limit: 100,
|
||||
columns: [],
|
||||
formulaColumns: [],
|
||||
};
|
||||
|
||||
// ─── 스키마 상태 ───────────────────────────────────────────────────────────────
|
||||
|
||||
const [tables, setTables] = useState<SchemaTable[]>([]);
|
||||
const [columns, setColumns] = useState<SchemaColumn[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [generatedSql, setGeneratedSql] = useState("");
|
||||
const [previewError, setPreviewError] = useState("");
|
||||
|
||||
// ─── 테이블 목록 로드 ──────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loadTables = async () => {
|
||||
setTablesLoading(true);
|
||||
try {
|
||||
const res = await reportApi.getSchemaTableList();
|
||||
if (!cancelled && res.success) setTables(res.data);
|
||||
} catch {
|
||||
/* 무시 */
|
||||
} finally {
|
||||
if (!cancelled) setTablesLoading(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// ─── 테이블 변경 시 컬럼 로드 ──────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!vq.tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const loadColumns = async () => {
|
||||
setColumnsLoading(true);
|
||||
try {
|
||||
const res = await reportApi.getSchemaTableColumns(vq.tableName);
|
||||
if (!cancelled && res.success) setColumns(res.data);
|
||||
} catch {
|
||||
/* 무시 */
|
||||
} finally {
|
||||
if (!cancelled) setColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
return () => { cancelled = true; };
|
||||
}, [vq.tableName]);
|
||||
|
||||
// ─── visualQuery 업데이트 헬퍼 ────────────────────────────────────────────────
|
||||
|
||||
const updateVQ = useCallback(
|
||||
(patch: Partial<VisualQuery>) => {
|
||||
updateComponent(component.id, { visualQuery: { ...vq, ...patch } });
|
||||
},
|
||||
[component.id, vq, updateComponent],
|
||||
);
|
||||
|
||||
// ─── 테이블 선택 ───────────────────────────────────────────────────────────────
|
||||
|
||||
const handleTableChange = useCallback(
|
||||
(tableName: string) => {
|
||||
updateComponent(component.id, {
|
||||
visualQuery: {
|
||||
tableName: tableName === "none" ? "" : tableName,
|
||||
limit: vq.limit ?? 100,
|
||||
columns: [],
|
||||
formulaColumns: [],
|
||||
},
|
||||
});
|
||||
},
|
||||
[component.id, vq.limit, updateComponent],
|
||||
);
|
||||
|
||||
// ─── 컬럼 토글 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const toggleColumn = useCallback(
|
||||
(colName: string) => {
|
||||
const selected = vq.columns.includes(colName)
|
||||
? vq.columns.filter((c) => c !== colName)
|
||||
: [...vq.columns, colName];
|
||||
updateVQ({ columns: selected });
|
||||
},
|
||||
[vq.columns, updateVQ],
|
||||
);
|
||||
|
||||
const selectAllColumns = useCallback(() => {
|
||||
updateVQ({ columns: columns.map((c) => c.column_name) });
|
||||
}, [columns, updateVQ]);
|
||||
|
||||
const deselectAllColumns = useCallback(() => {
|
||||
updateVQ({ columns: [] });
|
||||
}, [updateVQ]);
|
||||
|
||||
// ─── 수식 컬럼 관리 ────────────────────────────────────────────────────────────
|
||||
|
||||
const addFormulaColumn = useCallback(() => {
|
||||
const fc: VisualQueryFormulaColumn = {
|
||||
alias: `calc_${(vq.formulaColumns.length + 1)}`,
|
||||
header: `수식 ${vq.formulaColumns.length + 1}`,
|
||||
expression: "",
|
||||
};
|
||||
updateVQ({ formulaColumns: [...vq.formulaColumns, fc] });
|
||||
}, [vq.formulaColumns, updateVQ]);
|
||||
|
||||
const updateFormulaColumn = useCallback(
|
||||
(idx: number, patch: Partial<VisualQueryFormulaColumn>) => {
|
||||
const updated = vq.formulaColumns.map((fc, i) => (i === idx ? { ...fc, ...patch } : fc));
|
||||
updateVQ({ formulaColumns: updated });
|
||||
},
|
||||
[vq.formulaColumns, updateVQ],
|
||||
);
|
||||
|
||||
const removeFormulaColumn = useCallback(
|
||||
(idx: number) => {
|
||||
updateVQ({ formulaColumns: vq.formulaColumns.filter((_, i) => i !== idx) });
|
||||
},
|
||||
[vq.formulaColumns, updateVQ],
|
||||
);
|
||||
|
||||
// ─── SQL 미리보기 빌드 ────────────────────────────────────────────────────────
|
||||
|
||||
const previewSql = useMemo(() => {
|
||||
if (!vq.tableName || (vq.columns.length === 0 && vq.formulaColumns.length === 0)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const col of vq.columns) parts.push(`"${col}"`);
|
||||
for (const fc of vq.formulaColumns) {
|
||||
if (fc.expression && fc.alias) parts.push(`(${fc.expression}) AS "${fc.alias}"`);
|
||||
}
|
||||
if (parts.length === 0) return "";
|
||||
const limit = Math.min(Math.max(vq.limit ?? 100, 1), 10000);
|
||||
return `SELECT ${parts.join(", ")} FROM "${vq.tableName}" LIMIT ${limit}`;
|
||||
}, [vq]);
|
||||
|
||||
// ─── 미리보기 실행 ────────────────────────────────────────────────────────────
|
||||
|
||||
const resultKey = `visual_${component.id}`;
|
||||
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!vq.tableName || (vq.columns.length === 0 && vq.formulaColumns.length === 0)) return;
|
||||
setPreviewLoading(true);
|
||||
setPreviewError("");
|
||||
try {
|
||||
const res = await reportApi.previewVisualQuery(vq);
|
||||
if (res.success) {
|
||||
setQueryResult(resultKey, res.data.fields, res.data.rows);
|
||||
setGeneratedSql(res.data.sql);
|
||||
} else {
|
||||
setPreviewError("쿼리 실행에 실패했습니다.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setPreviewError(err?.response?.data?.message || err.message || "오류가 발생했습니다.");
|
||||
} finally {
|
||||
setPreviewLoading(false);
|
||||
}
|
||||
}, [vq, resultKey, setQueryResult]);
|
||||
|
||||
const hasSelection = vq.columns.length > 0 || vq.formulaColumns.length > 0;
|
||||
|
||||
// ─── 렌더링 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* [1] 데이터 소스 */}
|
||||
<Card className="border-blue-200 bg-blue-50">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-blue-600" />
|
||||
<CardTitle className="text-xs text-blue-900">데이터 소스</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">테이블</Label>
|
||||
<Select
|
||||
value={vq.tableName || "none"}
|
||||
onValueChange={handleTableChange}
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8 bg-white text-xs">
|
||||
<SelectValue placeholder={tablesLoading ? "로딩 중..." : "테이블 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{tables.map((t) => (
|
||||
<SelectItem key={t.table_name} value={t.table_name}>
|
||||
{t.table_name}
|
||||
<span className="ml-1 text-[10px] text-gray-400">
|
||||
{t.table_type === "VIEW" ? "(뷰)" : ""}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">최대 행 수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={vq.limit ?? 100}
|
||||
onChange={(e) => updateVQ({ limit: parseInt(e.target.value) || 100 })}
|
||||
className="h-8 bg-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* [2] 컬럼 선택 */}
|
||||
{vq.tableName && (
|
||||
<Card className="border-green-200 bg-green-50">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Columns3 className="h-4 w-4 text-green-600" />
|
||||
<CardTitle className="text-xs text-green-900">
|
||||
컬럼 선택
|
||||
{vq.columns.length > 0 && (
|
||||
<span className="ml-1 font-normal text-green-600">
|
||||
({vq.columns.length}/{columns.length})
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
onClick={selectAllColumns}
|
||||
>
|
||||
전체 선택
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
onClick={deselectAllColumns}
|
||||
>
|
||||
전체 해제
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{columnsLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-green-600" />
|
||||
<span className="ml-2 text-xs text-green-700">컬럼 로딩 중...</span>
|
||||
</div>
|
||||
) : columns.length > 0 ? (
|
||||
<div className="grid max-h-48 grid-cols-2 gap-1 overflow-y-auto">
|
||||
{columns.map((col) => {
|
||||
const checked = vq.columns.includes(col.column_name);
|
||||
return (
|
||||
<label
|
||||
key={col.column_name}
|
||||
className={`flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs transition-colors ${
|
||||
checked ? "bg-green-200/60 text-green-900" : "text-gray-600 hover:bg-green-100/40"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleColumn(col.column_name)}
|
||||
className="h-3.5 w-3.5 rounded border-gray-300 text-green-600"
|
||||
/>
|
||||
<span className="truncate font-mono">{col.column_name}</span>
|
||||
<span className="ml-auto shrink-0 text-[9px] text-gray-400">
|
||||
{col.data_type}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-4 text-center text-xs text-gray-500">컬럼 정보가 없습니다.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* [3] 수식 컬럼 */}
|
||||
{vq.tableName && (
|
||||
<Card className="border-amber-200 bg-amber-50">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FunctionSquare className="h-4 w-4 text-amber-600" />
|
||||
<CardTitle className="text-xs text-amber-900">
|
||||
수식 컬럼
|
||||
{vq.formulaColumns.length > 0 && (
|
||||
<span className="ml-1 font-normal text-amber-600">
|
||||
({vq.formulaColumns.length})
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 bg-white px-2 text-[10px]"
|
||||
onClick={addFormulaColumn}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{vq.formulaColumns.length > 0 ? (
|
||||
vq.formulaColumns.map((fc, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-start gap-1 rounded border border-amber-200 bg-white p-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={fc.alias}
|
||||
onChange={(e) => updateFormulaColumn(idx, { alias: e.target.value })}
|
||||
placeholder="필드명"
|
||||
className="h-7 flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={fc.header}
|
||||
onChange={(e) => updateFormulaColumn(idx, { header: e.target.value })}
|
||||
placeholder="표시 헤더"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={fc.expression}
|
||||
onChange={(e) => updateFormulaColumn(idx, { expression: e.target.value })}
|
||||
placeholder='SQL 표현식 (예: "price" * "quantity")'
|
||||
className="h-7 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="mt-0.5 h-6 w-6 shrink-0 p-0 text-gray-400 hover:text-red-500"
|
||||
onClick={() => removeFormulaColumn(idx)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="py-2 text-center text-[10px] text-gray-500">
|
||||
계산이 필요한 컬럼을 추가하세요. (예: price * quantity)
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* [4] SQL 미리보기 + 실행 */}
|
||||
{vq.tableName && hasSelection && (
|
||||
<Card className="border-gray-200 bg-gray-50">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4 text-gray-600" />
|
||||
<CardTitle className="text-xs text-gray-700">생성 SQL</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7 bg-blue-600 px-3 text-xs hover:bg-blue-700"
|
||||
onClick={handlePreview}
|
||||
disabled={previewLoading}
|
||||
>
|
||||
{previewLoading ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
미리보기
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="overflow-x-auto rounded bg-gray-900 p-2 font-mono text-[10px] leading-relaxed text-green-300">
|
||||
{generatedSql || previewSql || "컬럼을 선택하세요."}
|
||||
</pre>
|
||||
{previewError && (
|
||||
<p className="mt-2 text-xs text-red-600">{previewError}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import JsBarcode from "jsbarcode";
|
||||
import QRCode from "qrcode";
|
||||
import type { BarcodeRendererProps as BarcodeCanvasRendererProps } from "./types";
|
||||
|
||||
interface BarcodeProps {
|
||||
value: string;
|
||||
format: string;
|
||||
width: number;
|
||||
height: number;
|
||||
displayValue: boolean;
|
||||
lineColor: string;
|
||||
background: string;
|
||||
margin: number;
|
||||
}
|
||||
|
||||
function Barcode1D({ value, format, width, height, displayValue, lineColor, background, margin }: BarcodeProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !value) return;
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let isValid = true;
|
||||
let errorMsg = "";
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (format === "EAN13" && !/^\d{12,13}$/.test(trimmedValue)) {
|
||||
isValid = false;
|
||||
errorMsg = "EAN-13: 12~13자리 숫자 필요";
|
||||
} else if (format === "EAN8" && !/^\d{7,8}$/.test(trimmedValue)) {
|
||||
isValid = false;
|
||||
errorMsg = "EAN-8: 7~8자리 숫자 필요";
|
||||
} else if (format === "UPC" && !/^\d{11,12}$/.test(trimmedValue)) {
|
||||
isValid = false;
|
||||
errorMsg = "UPC: 11~12자리 숫자 필요";
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
setError(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
const barcodeFormat = format.toLowerCase();
|
||||
const bgColor = background === "transparent" ? "" : background;
|
||||
|
||||
JsBarcode(svgRef.current, trimmedValue, {
|
||||
format: barcodeFormat,
|
||||
width: 2,
|
||||
height: Math.max(30, height - (displayValue ? 30 : 10)),
|
||||
displayValue,
|
||||
lineColor,
|
||||
background: bgColor,
|
||||
margin,
|
||||
fontSize: 12,
|
||||
textMargin: 2,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "바코드 생성 실패");
|
||||
}
|
||||
}, [value, format, width, height, displayValue, lineColor, background, margin]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<svg ref={svgRef} className={`max-h-full max-w-full ${error ? "hidden" : ""}`} />
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-xs text-red-500">
|
||||
<span>{error}</span>
|
||||
<span className="mt-1 text-gray-400">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface QRProps {
|
||||
value: string;
|
||||
size: number;
|
||||
fgColor: string;
|
||||
bgColor: string;
|
||||
level: "L" | "M" | "Q" | "H";
|
||||
}
|
||||
|
||||
function QR({ value, size, fgColor, bgColor, level }: QRProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !value) return;
|
||||
setError(null);
|
||||
const lightColor = bgColor === "transparent" ? "#ffffff" : bgColor;
|
||||
|
||||
QRCode.toCanvas(
|
||||
canvasRef.current,
|
||||
value,
|
||||
{
|
||||
width: Math.max(50, size),
|
||||
margin: 2,
|
||||
color: { dark: fgColor, light: lightColor },
|
||||
errorCorrectionLevel: level,
|
||||
},
|
||||
(err) => {
|
||||
if (err) setError(err.message || "QR코드 생성 실패");
|
||||
},
|
||||
);
|
||||
}, [value, size, fgColor, bgColor, level]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<canvas ref={canvasRef} className={`max-h-full max-w-full ${error ? "hidden" : ""}`} />
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-xs text-red-500">
|
||||
<span>{error}</span>
|
||||
<span className="mt-1 text-gray-400">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarcodeCanvasRenderer({ component, getQueryResult }: BarcodeCanvasRendererProps) {
|
||||
const barcodeType = component.barcodeType || "CODE128";
|
||||
const showBarcodeText = component.showBarcodeText !== false;
|
||||
const barcodeColor = component.barcodeColor || "#000000";
|
||||
const barcodeBackground = component.barcodeBackground || "transparent";
|
||||
const barcodeMargin = component.barcodeMargin ?? 10;
|
||||
const qrErrorLevel = component.qrErrorCorrectionLevel || "M";
|
||||
|
||||
const getBarcodeValue = (): string => {
|
||||
if (
|
||||
barcodeType === "QR" &&
|
||||
component.qrUseMultiField &&
|
||||
component.qrDataFields &&
|
||||
component.qrDataFields.length > 0 &&
|
||||
component.queryId
|
||||
) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
if (component.qrIncludeAllRows) {
|
||||
const allRowsData: Record<string, string>[] = [];
|
||||
queryResult.rows.forEach((row) => {
|
||||
const rowData: Record<string, string> = {};
|
||||
component.qrDataFields!.forEach((field) => {
|
||||
if (field.fieldName && field.label) {
|
||||
const val = row[field.fieldName];
|
||||
rowData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
||||
}
|
||||
});
|
||||
allRowsData.push(rowData);
|
||||
});
|
||||
return JSON.stringify(allRowsData);
|
||||
}
|
||||
const row = queryResult.rows[0];
|
||||
const jsonData: Record<string, string> = {};
|
||||
component.qrDataFields.forEach((field) => {
|
||||
if (field.fieldName && field.label) {
|
||||
const val = row[field.fieldName];
|
||||
jsonData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
||||
}
|
||||
});
|
||||
return JSON.stringify(jsonData);
|
||||
}
|
||||
const placeholderData: Record<string, string> = {};
|
||||
component.qrDataFields.forEach((field) => {
|
||||
if (field.label) placeholderData[field.label] = `{${field.fieldName || "field"}}`;
|
||||
});
|
||||
return component.qrIncludeAllRows
|
||||
? JSON.stringify([placeholderData, { "...": "..." }])
|
||||
: JSON.stringify(placeholderData);
|
||||
}
|
||||
|
||||
if (component.barcodeFieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
if (barcodeType === "QR" && component.qrIncludeAllRows) {
|
||||
const allValues = queryResult.rows
|
||||
.map((row) => {
|
||||
const val = row[component.barcodeFieldName!];
|
||||
return val !== null && val !== undefined ? String(val) : "";
|
||||
})
|
||||
.filter((v) => v !== "");
|
||||
return JSON.stringify(allValues);
|
||||
}
|
||||
const row = queryResult.rows[0];
|
||||
const val = row[component.barcodeFieldName];
|
||||
if (val !== null && val !== undefined) return String(val);
|
||||
}
|
||||
if (barcodeType === "QR" && component.qrIncludeAllRows) {
|
||||
return JSON.stringify([`{${component.barcodeFieldName}}`, "..."]);
|
||||
}
|
||||
return `{${component.barcodeFieldName}}`;
|
||||
}
|
||||
return component.barcodeValue || "SAMPLE123";
|
||||
};
|
||||
|
||||
const barcodeValue = getBarcodeValue();
|
||||
const isQR = barcodeType === "QR";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center overflow-hidden"
|
||||
style={{ backgroundColor: barcodeBackground }}
|
||||
>
|
||||
{isQR ? (
|
||||
<QR
|
||||
value={barcodeValue}
|
||||
size={Math.min(component.width, component.height) - 10}
|
||||
fgColor={barcodeColor}
|
||||
bgColor={barcodeBackground}
|
||||
level={qrErrorLevel}
|
||||
/>
|
||||
) : (
|
||||
<Barcode1D
|
||||
value={barcodeValue}
|
||||
format={barcodeType}
|
||||
width={component.width}
|
||||
height={component.height}
|
||||
displayValue={showBarcodeText}
|
||||
lineColor={barcodeColor}
|
||||
background={barcodeBackground}
|
||||
margin={barcodeMargin}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import type { CalculationRendererProps } from "./types";
|
||||
|
||||
export function CalculationRenderer({ component, getQueryResult }: CalculationRendererProps) {
|
||||
const calcItems = component.calcItems || [];
|
||||
const resultLabel = component.resultLabel || "합계";
|
||||
const calcLabelWidth = component.labelWidth || 120;
|
||||
const calcLabelFontSize = component.labelFontSize || 13;
|
||||
const calcValueFontSize = component.valueFontSize || 13;
|
||||
const calcResultFontSize = component.resultFontSize || 16;
|
||||
const calcLabelColor = component.labelColor || "#374151";
|
||||
const calcValueColor = component.valueColor || "#000000";
|
||||
const calcResultColor = component.resultColor || "#2563eb";
|
||||
const numberFormat = component.numberFormat || "currency";
|
||||
const currencySuffix = component.currencySuffix || "원";
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (numberFormat === "none") return String(num);
|
||||
if (numberFormat === "comma") return num.toLocaleString();
|
||||
if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
|
||||
return String(num);
|
||||
};
|
||||
|
||||
const getCalcItemValue = (item: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
operator: string;
|
||||
fieldName?: string;
|
||||
}): number => {
|
||||
if (item.fieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const row = queryResult.rows[0];
|
||||
const val = row[item.fieldName];
|
||||
return typeof val === "number" ? val : parseFloat(String(val)) || 0;
|
||||
}
|
||||
}
|
||||
return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
|
||||
};
|
||||
|
||||
const calculateResult = (): number => {
|
||||
if (calcItems.length === 0) return 0;
|
||||
let result = getCalcItemValue(
|
||||
calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string },
|
||||
);
|
||||
for (let i = 1; i < calcItems.length; i++) {
|
||||
const item = calcItems[i];
|
||||
const val = getCalcItemValue(
|
||||
item as { label: string; value: number | string; operator: string; fieldName?: string },
|
||||
);
|
||||
switch (item.operator) {
|
||||
case "+":
|
||||
result += val;
|
||||
break;
|
||||
case "-":
|
||||
result -= val;
|
||||
break;
|
||||
case "x":
|
||||
result *= val;
|
||||
break;
|
||||
case "÷":
|
||||
result = val !== 0 ? result / val : result;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const calcResult = calculateResult();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-auto px-2 py-1">
|
||||
{calcItems.map(
|
||||
(item: { label: string; value: number | string; operator: string; fieldName?: string }, index: number) => {
|
||||
const itemValue = getCalcItemValue(item);
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between py-1">
|
||||
<span
|
||||
className="flex-shrink-0"
|
||||
style={{ width: `${calcLabelWidth}px`, fontSize: `${calcLabelFontSize}px`, color: calcLabelColor }}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="text-right" style={{ fontSize: `${calcValueFontSize}px`, color: calcValueColor }}>
|
||||
{formatNumber(itemValue)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-1 flex-shrink-0 border-t" style={{ borderColor: component.borderColor || "#374151" }} />
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<span
|
||||
className="font-semibold"
|
||||
style={{ width: `${calcLabelWidth}px`, fontSize: `${calcResultFontSize}px`, color: calcLabelColor }}
|
||||
>
|
||||
{resultLabel}
|
||||
</span>
|
||||
<span className="text-right font-bold" style={{ fontSize: `${calcResultFontSize}px`, color: calcResultColor }}>
|
||||
{formatNumber(calcResult)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user