feat: 리포트 타입 에러 수정

This commit is contained in:
shin
2026-03-12 18:47:42 +09:00
parent a3aae8aeda
commit 930727a5cb
140 changed files with 27027 additions and 9323 deletions
+85
View File
@@ -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`
+54
View File
@@ -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가 실제 전달과 일치
## 피드백 형식
- **치명적**: 반드시 수정 (보안, 빌드 실패)
- **경고**: 수정 권장 (성능, 유지보수성)
- **제안**: 선택적 개선
- **범위 밖 발견**: 리포트 외 파일 문제 (수정 금지, 보고만)
+51
View File
@@ -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 필터링 누락
- 리포트 데이터 직렬화/역직렬화 오류
## 출력 형식
각 이슈에 대해:
- 근본 원인 설명
- 수정 파일이 허용 범위 내인지 명시
- 구체적 코드 수정 (허용 범위 내만)
- 테스트 방법
+58
View File
@@ -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)
## 리스크
- ...
```
+37
View File
@@ -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) | 전체 보기 모달 |
+18
View File
@@ -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`
+24
View File
@@ -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에 포함시켜야 합니다.
+34
View File
@@ -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
```
+43
View File
@@ -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
## 코드 리뷰 결과
### 치명적 (반드시 수정)
- [파일:라인] 설명
### 범위 밖 발견 (수정 금지, 보고만)
- [파일] 설명
```
+47
View File
@@ -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";
```
+38
View File
@@ -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 수정 금지
+37
View File
@@ -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 정합성 확인
- 멀티테넌시 체크리스트 검증
+37
View File
@@ -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`
+572
View File
@@ -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를 사용했는가
+46
View File
@@ -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`에 새 계획 추가
+51
View File
@@ -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`
+46
View File
@@ -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', , '회사코드');
```
+55
View File
@@ -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`, 고정 높이
+70
View File
@@ -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)
```
+70
View File
@@ -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 자동 감지
- 속성 패널
- 프리뷰 모달
### 공통
- 스크롤 정상
- 콘솔 에러 없음
+185 -305
View File
@@ -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();
+21
View File
@@ -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
+132 -56
View File
@@ -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.
+17
View File
@@ -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}></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]">
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</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} />
</>
);
}
+301 -120
View File
@@ -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>
&quot;<span className="font-medium">{customCategory.trim()}</span>&quot;
</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>
);
}
+510 -120
View File
@@ -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
+8 -10
View File
@@ -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">&mdash;</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">&mdash;</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">&mdash;</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