Merge branch 'feature/v2-unified-renewal' of http://39.117.244.52:3000/kjs/ERP-node into feature/v2-renewal
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
commit
8c96b2d185
|
|
@ -671,14 +671,14 @@ export async function getTableRecord(
|
||||||
logger.info(`필터: ${filterColumn} = ${filterValue}`);
|
logger.info(`필터: ${filterColumn} = ${filterValue}`);
|
||||||
logger.info(`표시 컬럼: ${displayColumn}`);
|
logger.info(`표시 컬럼: ${displayColumn}`);
|
||||||
|
|
||||||
if (!tableName || !filterColumn || !filterValue || !displayColumn) {
|
if (!tableName || !filterColumn || !filterValue) {
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
success: false,
|
success: false,
|
||||||
message: "필수 파라미터가 누락되었습니다.",
|
message: "필수 파라미터가 누락되었습니다.",
|
||||||
error: {
|
error: {
|
||||||
code: "MISSING_PARAMETERS",
|
code: "MISSING_PARAMETERS",
|
||||||
details:
|
details:
|
||||||
"tableName, filterColumn, filterValue, displayColumn이 필요합니다.",
|
"tableName, filterColumn, filterValue가 필요합니다. displayColumn은 선택적입니다.",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
res.status(400).json(response);
|
res.status(400).json(response);
|
||||||
|
|
@ -710,9 +710,12 @@ export async function getTableRecord(
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = result.data[0];
|
const record = result.data[0];
|
||||||
const displayValue = record[displayColumn];
|
// displayColumn이 "*"이거나 없으면 전체 레코드 반환
|
||||||
|
const displayValue = displayColumn && displayColumn !== "*"
|
||||||
|
? record[displayColumn]
|
||||||
|
: record;
|
||||||
|
|
||||||
logger.info(`레코드 조회 완료: ${displayColumn} = ${displayValue}`);
|
logger.info(`레코드 조회 완료: ${displayColumn || "*"} = ${typeof displayValue === 'object' ? '[전체 레코드]' : displayValue}`);
|
||||||
|
|
||||||
const response: ApiResponse<{ value: any; record: any }> = {
|
const response: ApiResponse<{ value: any; record: any }> = {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,12 @@ frontend/lib/registry/components/
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## V2 컴포넌트 목록 (17개)
|
## V2 컴포넌트 목록 (18개)
|
||||||
|
|
||||||
| 컴포넌트 ID | 이름 | 카테고리 | 용도 |
|
| 컴포넌트 ID | 이름 | 카테고리 | 용도 |
|
||||||
|------------|------|----------|------|
|
|------------|------|----------|------|
|
||||||
| `v2-table-list` | 테이블 리스트 | DISPLAY | 데이터 목록 표시 (테이블/카드 모드) |
|
| `v2-table-list` | 테이블 리스트 | DISPLAY | 데이터 목록 표시 (테이블/카드 모드) |
|
||||||
|
| `v2-table-grouped` | 그룹화 테이블 | DISPLAY | **NEW** 그룹별 접기/펼치기 테이블 |
|
||||||
| `v2-split-panel-layout` | 분할 패널 | DISPLAY | 마스터-디테일 레이아웃 |
|
| `v2-split-panel-layout` | 분할 패널 | DISPLAY | 마스터-디테일 레이아웃 |
|
||||||
| `v2-unified-repeater` | 통합 리피터 | UNIFIED | 반복 데이터 관리 (인라인/모달/버튼) |
|
| `v2-unified-repeater` | 통합 리피터 | UNIFIED | 반복 데이터 관리 (인라인/모달/버튼) |
|
||||||
| `v2-pivot-grid` | 피벗 그리드 | DISPLAY | 다차원 데이터 분석 피벗 테이블 |
|
| `v2-pivot-grid` | 피벗 그리드 | DISPLAY | 다차원 데이터 분석 피벗 테이블 |
|
||||||
|
|
@ -182,12 +183,43 @@ interface ColumnConfig {
|
||||||
|
|
||||||
#### 주요 특징
|
#### 주요 특징
|
||||||
|
|
||||||
- 좌측: 마스터 목록 (리스트/테이블 모드)
|
- 좌측: 마스터 목록 (리스트/테이블/커스텀 모드)
|
||||||
- 우측: 디테일 정보 (연관 데이터)
|
- 우측: 디테일 정보 (연관 데이터)
|
||||||
- 좌우 비율 조절 가능 (드래그 리사이즈)
|
- 좌우 비율 조절 가능 (드래그 리사이즈)
|
||||||
- 다중 탭 지원 (우측 패널)
|
- 다중 탭 지원 (우측 패널)
|
||||||
- N:M 관계 데이터 지원
|
- N:M 관계 데이터 지원
|
||||||
- 중복 제거 기능
|
- 중복 제거 기능
|
||||||
|
- **커스텀 모드 (NEW)**: 패널 내부에 자유롭게 컴포넌트 배치
|
||||||
|
|
||||||
|
#### 커스텀 모드 (displayMode: "custom")
|
||||||
|
|
||||||
|
패널 내부에 다양한 컴포넌트를 자유롭게 배치할 수 있는 모드입니다. **v2-tabs-widget과 동일한 구조**로 구현되어 있습니다.
|
||||||
|
|
||||||
|
**특징:**
|
||||||
|
- 패널 내부에 버튼, 테이블, 입력 필드 등 모든 V2 컴포넌트 배치 가능
|
||||||
|
- 드래그앤드롭으로 컴포넌트 이동
|
||||||
|
- 리사이즈 핸들로 크기 조절
|
||||||
|
- **컴포넌트 클릭 시 좌측 설정 패널에서 해당 컴포넌트 속성 편집**
|
||||||
|
- 디자인 모드에서 실제 컴포넌트가 렌더링되어 미리보기 가능
|
||||||
|
|
||||||
|
**사용 방법:**
|
||||||
|
1. 좌측/우측 패널의 `displayMode`를 `"custom"`으로 설정
|
||||||
|
2. 컴포넌트 팔레트에서 원하는 컴포넌트를 패널로 드래그
|
||||||
|
3. 배치된 컴포넌트 클릭 → 좌측 패널에서 속성 편집
|
||||||
|
4. 드래그 핸들(상단)로 이동, 모서리 핸들로 크기 조절
|
||||||
|
|
||||||
|
**컴포넌트 구조:**
|
||||||
|
```typescript
|
||||||
|
interface PanelInlineComponent {
|
||||||
|
id: string;
|
||||||
|
componentType: string;
|
||||||
|
label?: string;
|
||||||
|
position?: { x: number; y: number };
|
||||||
|
size?: { width: number; height: number };
|
||||||
|
componentConfig?: Record<string, any>;
|
||||||
|
style?: Record<string, any>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### 데이터 흐름
|
#### 데이터 흐름
|
||||||
|
|
||||||
|
|
@ -220,7 +252,7 @@ interface SplitPanelLayoutConfig {
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
useCustomTable?: boolean;
|
useCustomTable?: boolean;
|
||||||
customTableName?: string;
|
customTableName?: string;
|
||||||
displayMode?: "list" | "table";
|
displayMode?: "list" | "table" | "custom"; // 🆕 커스텀 모드 추가
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
showAdd?: boolean;
|
showAdd?: boolean;
|
||||||
showEdit?: boolean;
|
showEdit?: boolean;
|
||||||
|
|
@ -228,15 +260,21 @@ interface SplitPanelLayoutConfig {
|
||||||
columns?: ColumnConfig[];
|
columns?: ColumnConfig[];
|
||||||
tableConfig?: TableDisplayConfig;
|
tableConfig?: TableDisplayConfig;
|
||||||
dataFilter?: DataFilterConfig;
|
dataFilter?: DataFilterConfig;
|
||||||
|
|
||||||
|
// 🆕 커스텀 모드 전용: 내부 컴포넌트 배열
|
||||||
|
components?: PanelInlineComponent[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// 우측 패널
|
// 우측 패널
|
||||||
rightPanel: {
|
rightPanel: {
|
||||||
title: string;
|
title: string;
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
displayMode?: "list" | "table";
|
displayMode?: "list" | "table" | "custom"; // 🆕 커스텀 모드 추가
|
||||||
columns?: ColumnConfig[];
|
columns?: ColumnConfig[];
|
||||||
|
|
||||||
|
// 🆕 커스텀 모드 전용: 내부 컴포넌트 배열
|
||||||
|
components?: PanelInlineComponent[];
|
||||||
|
|
||||||
// 관계 설정
|
// 관계 설정
|
||||||
relation?: {
|
relation?: {
|
||||||
type?: "join" | "detail";
|
type?: "join" | "detail";
|
||||||
|
|
|
||||||
|
|
@ -959,6 +959,29 @@ receiveData(data, config): Promise<void>
|
||||||
**구독 이벤트**:
|
**구독 이벤트**:
|
||||||
- `refreshTable` - 내부 테이블 갱신
|
- `refreshTable` - 내부 테이블 갱신
|
||||||
|
|
||||||
|
**커스텀 모드 (displayMode: "custom")**:
|
||||||
|
- 패널 내부에 자유롭게 컴포넌트 배치 가능 (v2-tabs-widget과 동일 구조)
|
||||||
|
- 컴포넌트 클릭 시 좌측 설정 패널에서 속성 편집
|
||||||
|
- 드래그앤드롭으로 컴포넌트 이동, 리사이즈 핸들로 크기 조절
|
||||||
|
- 디자인 모드에서 실제 컴포넌트 렌더링 (미리보기)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 커스텀 모드 설정 예시
|
||||||
|
leftPanel: {
|
||||||
|
displayMode: "custom",
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
id: "btn-1",
|
||||||
|
componentType: "v2-button-primary",
|
||||||
|
label: "저장",
|
||||||
|
position: { x: 10, y: 10 },
|
||||||
|
size: { width: 100, height: 40 },
|
||||||
|
componentConfig: { buttonAction: "save" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 연동 가능한 조합
|
## 7. 연동 가능한 조합
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,581 @@
|
||||||
|
# 다음 구현 필요 컴포넌트 개발 계획
|
||||||
|
|
||||||
|
> **작성일**: 2026-01-30
|
||||||
|
> **상태**: 계획 수립 완료
|
||||||
|
> **우선순위**: v2-table-grouped (1순위) → v2-timeline-scheduler (2순위)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
생산계획관리 화면의 정식 버전 구현을 위해 필요한 2개의 신규 컴포넌트 개발 계획입니다.
|
||||||
|
|
||||||
|
| 컴포넌트 | 용도 | 난이도 | 예상 작업량 |
|
||||||
|
|----------|------|:------:|:----------:|
|
||||||
|
| `v2-table-grouped` | 그룹화 테이블 (접기/펼치기) | 중 | 2-3일 |
|
||||||
|
| `v2-timeline-scheduler` | 타임라인/간트차트 스케줄러 | 상 | 5-7일 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. v2-table-grouped (그룹화 테이블)
|
||||||
|
|
||||||
|
### 1.1 컴포넌트 개요
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| **컴포넌트 ID** | `v2-table-grouped` |
|
||||||
|
| **카테고리** | DISPLAY |
|
||||||
|
| **용도** | 데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능 제공 |
|
||||||
|
| **기반 컴포넌트** | `v2-table-list` 확장 |
|
||||||
|
| **참고 UI** | Excel 그룹화, VS Code 파일 그룹화 |
|
||||||
|
|
||||||
|
### 1.2 핵심 기능
|
||||||
|
|
||||||
|
| 기능 | 설명 | 우선순위 |
|
||||||
|
|------|------|:--------:|
|
||||||
|
| 그룹화 | 지정된 컬럼 기준으로 데이터 그룹핑 | 필수 |
|
||||||
|
| 접기/펼치기 | 그룹 행 클릭 시 하위 항목 토글 | 필수 |
|
||||||
|
| 그룹 요약 | 그룹별 합계/개수 표시 | 필수 |
|
||||||
|
| 다중 그룹 | 여러 컬럼 기준 중첩 그룹화 | 선택 |
|
||||||
|
| 그룹 선택 | 그룹 체크박스로 하위 전체 선택 | 필수 |
|
||||||
|
| 전체 펼치기/접기 | 모든 그룹 일괄 토글 | 필수 |
|
||||||
|
|
||||||
|
### 1.3 UI 목업
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ [전체 펼치기] [전체 접기] [3개 그룹] │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ▼ □ 품목A (P001) 수량: 150 3건 │
|
||||||
|
│ ├─ □ 2026-01-15 생산계획001 50개 설비A │
|
||||||
|
│ ├─ □ 2026-01-16 생산계획002 50개 설비B │
|
||||||
|
│ └─ □ 2026-01-17 생산계획003 50개 설비A │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ► □ 품목B (P002) 수량: 200 2건 │ ← 접힌 상태
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ▼ □ 품목C (P003) 수량: 100 1건 │
|
||||||
|
│ └─ □ 2026-01-18 생산계획004 100개 설비C │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 타입 정의 (types.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ColumnConfig } from "../v2-table-list/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹화 설정
|
||||||
|
*/
|
||||||
|
export interface GroupConfig {
|
||||||
|
/** 그룹화 기준 컬럼 */
|
||||||
|
groupByColumn: string;
|
||||||
|
|
||||||
|
/** 그룹 표시 형식 (예: "{item_name} ({item_code})") */
|
||||||
|
groupLabelFormat?: string;
|
||||||
|
|
||||||
|
/** 그룹 요약 설정 */
|
||||||
|
summary?: {
|
||||||
|
/** 합계 컬럼 */
|
||||||
|
sumColumns?: string[];
|
||||||
|
/** 개수 표시 여부 */
|
||||||
|
showCount?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 초기 펼침 상태 */
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
|
||||||
|
/** 중첩 그룹 (다중 그룹화) */
|
||||||
|
nestedGroup?: GroupConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹화 테이블 설정
|
||||||
|
*/
|
||||||
|
export interface TableGroupedConfig {
|
||||||
|
/** 테이블명 */
|
||||||
|
selectedTable?: string;
|
||||||
|
|
||||||
|
/** 커스텀 테이블 사용 */
|
||||||
|
useCustomTable?: boolean;
|
||||||
|
customTableName?: string;
|
||||||
|
|
||||||
|
/** 그룹화 설정 */
|
||||||
|
groupConfig: GroupConfig;
|
||||||
|
|
||||||
|
/** 컬럼 설정 (v2-table-list와 동일) */
|
||||||
|
columns?: ColumnConfig[];
|
||||||
|
|
||||||
|
/** 체크박스 표시 */
|
||||||
|
showCheckbox?: boolean;
|
||||||
|
|
||||||
|
/** 체크박스 모드 */
|
||||||
|
checkboxMode?: "single" | "multi";
|
||||||
|
|
||||||
|
/** 페이지네이션 (그룹 단위) */
|
||||||
|
pagination?: {
|
||||||
|
enabled: boolean;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 정렬 설정 */
|
||||||
|
defaultSort?: {
|
||||||
|
column: string;
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 상태
|
||||||
|
*/
|
||||||
|
export interface GroupState {
|
||||||
|
/** 그룹 키 (groupByColumn 값) */
|
||||||
|
groupKey: string;
|
||||||
|
|
||||||
|
/** 펼침 여부 */
|
||||||
|
expanded: boolean;
|
||||||
|
|
||||||
|
/** 그룹 내 데이터 */
|
||||||
|
items: any[];
|
||||||
|
|
||||||
|
/** 그룹 요약 데이터 */
|
||||||
|
summary?: Record<string, number>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.5 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/lib/registry/components/v2-table-grouped/
|
||||||
|
├── index.ts # Definition (V2TableGroupedDefinition)
|
||||||
|
├── types.ts # 타입 정의
|
||||||
|
├── config.ts # 기본 설정값
|
||||||
|
├── TableGroupedComponent.tsx # 메인 컴포넌트
|
||||||
|
├── TableGroupedConfigPanel.tsx # 설정 패널
|
||||||
|
├── TableGroupedRenderer.tsx # 레지스트리 등록
|
||||||
|
├── components/
|
||||||
|
│ ├── GroupHeader.tsx # 그룹 헤더 (펼치기/접기)
|
||||||
|
│ ├── GroupSummary.tsx # 그룹 요약
|
||||||
|
│ └── GroupCheckbox.tsx # 그룹 체크박스
|
||||||
|
├── hooks/
|
||||||
|
│ └── useGroupedData.ts # 그룹화 로직 훅
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.6 구현 단계
|
||||||
|
|
||||||
|
| 단계 | 작업 내용 | 예상 시간 |
|
||||||
|
|:----:|----------|:---------:|
|
||||||
|
| 1 | 타입 정의 및 기본 구조 생성 | 2시간 |
|
||||||
|
| 2 | `useGroupedData` 훅 구현 (데이터 그룹화 로직) | 4시간 |
|
||||||
|
| 3 | `GroupHeader` 컴포넌트 (펼치기/접기 UI) | 2시간 |
|
||||||
|
| 4 | `TableGroupedComponent` 메인 구현 | 6시간 |
|
||||||
|
| 5 | 그룹 체크박스 연동 | 2시간 |
|
||||||
|
| 6 | 그룹 요약 (합계/개수) | 2시간 |
|
||||||
|
| 7 | `TableGroupedConfigPanel` 설정 패널 | 4시간 |
|
||||||
|
| 8 | 테스트 및 문서화 | 2시간 |
|
||||||
|
|
||||||
|
**총 예상: 24시간 (약 3일)**
|
||||||
|
|
||||||
|
### 1.7 v2-table-list와의 차이점
|
||||||
|
|
||||||
|
| 항목 | v2-table-list | v2-table-grouped |
|
||||||
|
|------|---------------|------------------|
|
||||||
|
| 데이터 구조 | 플랫 리스트 | 계층 구조 (그룹 > 아이템) |
|
||||||
|
| 렌더링 | 행 단위 | 그룹 헤더 + 상세 행 |
|
||||||
|
| 선택 | 개별 행 | 그룹 단위 / 개별 단위 |
|
||||||
|
| 요약 | 전체 합계 (선택) | 그룹별 요약 |
|
||||||
|
| 페이지네이션 | 행 단위 | 그룹 단위 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. v2-timeline-scheduler (타임라인 스케줄러)
|
||||||
|
|
||||||
|
### 2.1 컴포넌트 개요
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| **컴포넌트 ID** | `v2-timeline-scheduler` |
|
||||||
|
| **카테고리** | DISPLAY |
|
||||||
|
| **용도** | 간트차트 형태의 일정/계획 시각화 및 편집 |
|
||||||
|
| **참고 UI** | MS Project, Jira Timeline, dhtmlxGantt |
|
||||||
|
| **외부 라이브러리** | 고려 중: `@tanstack/react-virtual` (가상 스크롤) |
|
||||||
|
|
||||||
|
### 2.2 핵심 기능
|
||||||
|
|
||||||
|
| 기능 | 설명 | 우선순위 |
|
||||||
|
|------|------|:--------:|
|
||||||
|
| 타임라인 그리드 | 날짜 기준 그리드 표시 (일/주/월) | 필수 |
|
||||||
|
| 스케줄 바 | 시작~종료 기간 바 렌더링 | 필수 |
|
||||||
|
| 리소스 행 | 설비/작업자별 행 구분 | 필수 |
|
||||||
|
| 드래그 이동 | 스케줄 바 드래그로 날짜 변경 | 필수 |
|
||||||
|
| 리사이즈 | 바 양쪽 핸들로 기간 조정 | 필수 |
|
||||||
|
| 줌 레벨 | 일/주/월 단위 전환 | 필수 |
|
||||||
|
| 충돌 표시 | 같은 리소스 시간 겹침 경고 | 선택 |
|
||||||
|
| 진행률 표시 | 바 내부 진행률 표시 | 선택 |
|
||||||
|
| 마일스톤 | 단일 일정 마커 | 선택 |
|
||||||
|
|
||||||
|
### 2.3 UI 목업
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ [◀ 이전] [오늘] [다음 ▶] 2026년 1월 [일] [주] [월] [+ 추가] │
|
||||||
|
├────────────┬─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │ 15(수) │ 16(목) │ 17(금) │ 18(토) │ 19(일) │ 20(월) │ 21(화) │
|
||||||
|
├────────────┼─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 설비A │ ████████████████ │
|
||||||
|
│ │ [생산계획001] │ │
|
||||||
|
├────────────┼─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 설비B │ █████████████████████████ │
|
||||||
|
│ │ [생산계획002 ] │
|
||||||
|
├────────────┼─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 설비C │ ████████████████ │
|
||||||
|
│ │ [생산계획003] │
|
||||||
|
└────────────┴─────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
범례: ██ 진행중 ██ 완료 ██ 지연 ◆ 마일스톤
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 타입 정의 (types.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 줌 레벨
|
||||||
|
*/
|
||||||
|
export type ZoomLevel = "day" | "week" | "month";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 상태
|
||||||
|
*/
|
||||||
|
export type ScheduleStatus = "planned" | "in_progress" | "completed" | "delayed" | "cancelled";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 항목
|
||||||
|
*/
|
||||||
|
export interface ScheduleItem {
|
||||||
|
/** 고유 ID */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** 리소스 ID (설비/작업자) */
|
||||||
|
resourceId: string;
|
||||||
|
|
||||||
|
/** 표시 제목 */
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/** 시작 일시 */
|
||||||
|
startDate: string; // ISO 8601 format
|
||||||
|
|
||||||
|
/** 종료 일시 */
|
||||||
|
endDate: string;
|
||||||
|
|
||||||
|
/** 상태 */
|
||||||
|
status: ScheduleStatus;
|
||||||
|
|
||||||
|
/** 진행률 (0-100) */
|
||||||
|
progress?: number;
|
||||||
|
|
||||||
|
/** 색상 (CSS color) */
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
/** 추가 데이터 */
|
||||||
|
data?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리소스 (행)
|
||||||
|
*/
|
||||||
|
export interface Resource {
|
||||||
|
/** 리소스 ID */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** 표시명 */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** 그룹 (선택) */
|
||||||
|
group?: string;
|
||||||
|
|
||||||
|
/** 아이콘 (선택) */
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/** 용량 (선택, 충돌 계산용) */
|
||||||
|
capacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임라인 설정
|
||||||
|
*/
|
||||||
|
export interface TimelineSchedulerConfig {
|
||||||
|
/** 테이블명 (스케줄 데이터) */
|
||||||
|
selectedTable?: string;
|
||||||
|
|
||||||
|
/** 리소스 테이블명 */
|
||||||
|
resourceTable?: string;
|
||||||
|
|
||||||
|
/** 필드 매핑 */
|
||||||
|
fieldMapping: {
|
||||||
|
id: string;
|
||||||
|
resourceId: string;
|
||||||
|
title: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
status?: string;
|
||||||
|
progress?: string;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 리소스 필드 매핑 */
|
||||||
|
resourceFieldMapping?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
group?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 초기 줌 레벨 */
|
||||||
|
defaultZoomLevel?: ZoomLevel;
|
||||||
|
|
||||||
|
/** 초기 표시 날짜 */
|
||||||
|
initialDate?: string;
|
||||||
|
|
||||||
|
/** 편집 가능 여부 */
|
||||||
|
editable?: boolean;
|
||||||
|
|
||||||
|
/** 드래그 이동 허용 */
|
||||||
|
allowDrag?: boolean;
|
||||||
|
|
||||||
|
/** 리사이즈 허용 */
|
||||||
|
allowResize?: boolean;
|
||||||
|
|
||||||
|
/** 충돌 체크 */
|
||||||
|
checkConflicts?: boolean;
|
||||||
|
|
||||||
|
/** 상태별 색상 */
|
||||||
|
statusColors?: Record<ScheduleStatus, string>;
|
||||||
|
|
||||||
|
/** 리소스 컬럼 너비 */
|
||||||
|
resourceColumnWidth?: number;
|
||||||
|
|
||||||
|
/** 행 높이 */
|
||||||
|
rowHeight?: number;
|
||||||
|
|
||||||
|
/** 셀 너비 (줌 레벨별) */
|
||||||
|
cellWidth?: {
|
||||||
|
day: number;
|
||||||
|
week: number;
|
||||||
|
month: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 툴바 표시 */
|
||||||
|
showToolbar?: boolean;
|
||||||
|
|
||||||
|
/** 범례 표시 */
|
||||||
|
showLegend?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 핸들러
|
||||||
|
*/
|
||||||
|
export interface TimelineEvents {
|
||||||
|
/** 스케줄 클릭 */
|
||||||
|
onScheduleClick?: (schedule: ScheduleItem) => void;
|
||||||
|
|
||||||
|
/** 스케줄 더블클릭 */
|
||||||
|
onScheduleDoubleClick?: (schedule: ScheduleItem) => void;
|
||||||
|
|
||||||
|
/** 드래그 완료 */
|
||||||
|
onScheduleDrag?: (schedule: ScheduleItem, newStart: Date, newEnd: Date) => void;
|
||||||
|
|
||||||
|
/** 리사이즈 완료 */
|
||||||
|
onScheduleResize?: (schedule: ScheduleItem, newStart: Date, newEnd: Date) => void;
|
||||||
|
|
||||||
|
/** 빈 영역 클릭 (새 스케줄 추가용) */
|
||||||
|
onEmptyClick?: (resourceId: string, date: Date) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.5 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/lib/registry/components/v2-timeline-scheduler/
|
||||||
|
├── index.ts # Definition (V2TimelineSchedulerDefinition)
|
||||||
|
├── types.ts # 타입 정의
|
||||||
|
├── config.ts # 기본 설정값
|
||||||
|
├── TimelineSchedulerComponent.tsx # 메인 컴포넌트
|
||||||
|
├── TimelineSchedulerConfigPanel.tsx # 설정 패널
|
||||||
|
├── TimelineSchedulerRenderer.tsx # 레지스트리 등록
|
||||||
|
├── components/
|
||||||
|
│ ├── TimelineHeader.tsx # 날짜 헤더
|
||||||
|
│ ├── TimelineGrid.tsx # 그리드 배경
|
||||||
|
│ ├── ResourceColumn.tsx # 리소스 컬럼 (좌측)
|
||||||
|
│ ├── ScheduleBar.tsx # 스케줄 바 (드래그/리사이즈)
|
||||||
|
│ ├── TimelineToolbar.tsx # 툴바 (줌, 네비게이션)
|
||||||
|
│ ├── TimelineLegend.tsx # 범례
|
||||||
|
│ └── ConflictIndicator.tsx # 충돌 표시
|
||||||
|
├── hooks/
|
||||||
|
│ ├── useTimelineState.ts # 타임라인 상태 관리
|
||||||
|
│ ├── useScheduleDrag.ts # 드래그 로직
|
||||||
|
│ ├── useScheduleResize.ts # 리사이즈 로직
|
||||||
|
│ └── useDateCalculation.ts # 날짜/위치 계산
|
||||||
|
├── utils/
|
||||||
|
│ ├── dateUtils.ts # 날짜 유틸리티
|
||||||
|
│ └── conflictDetection.ts # 충돌 감지
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.6 구현 단계
|
||||||
|
|
||||||
|
| 단계 | 작업 내용 | 예상 시간 |
|
||||||
|
|:----:|----------|:---------:|
|
||||||
|
| 1 | 타입 정의 및 기본 구조 생성 | 3시간 |
|
||||||
|
| 2 | `TimelineHeader` (날짜 헤더, 줌 레벨) | 4시간 |
|
||||||
|
| 3 | `TimelineGrid` (그리드 배경) | 3시간 |
|
||||||
|
| 4 | `ResourceColumn` (리소스 목록) | 2시간 |
|
||||||
|
| 5 | `ScheduleBar` 기본 렌더링 | 4시간 |
|
||||||
|
| 6 | 드래그 이동 구현 | 6시간 |
|
||||||
|
| 7 | 리사이즈 구현 | 4시간 |
|
||||||
|
| 8 | 줌 레벨 전환 (일/주/월) | 3시간 |
|
||||||
|
| 9 | 날짜 네비게이션 | 2시간 |
|
||||||
|
| 10 | 충돌 감지 및 표시 | 4시간 |
|
||||||
|
| 11 | 가상 스크롤 (대용량 데이터) | 4시간 |
|
||||||
|
| 12 | `TimelineSchedulerConfigPanel` | 4시간 |
|
||||||
|
| 13 | API 연동 (저장/로드) | 4시간 |
|
||||||
|
| 14 | 테스트 및 문서화 | 3시간 |
|
||||||
|
|
||||||
|
**총 예상: 50시간 (약 6-7일)**
|
||||||
|
|
||||||
|
### 2.7 핵심 알고리즘
|
||||||
|
|
||||||
|
#### 날짜 → 픽셀 위치 변환
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function dateToPosition(date: Date, viewStart: Date, cellWidth: number, zoomLevel: ZoomLevel): number {
|
||||||
|
const diffMs = date.getTime() - viewStart.getTime();
|
||||||
|
|
||||||
|
switch (zoomLevel) {
|
||||||
|
case "day":
|
||||||
|
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||||
|
return diffDays * cellWidth;
|
||||||
|
case "week":
|
||||||
|
const diffWeeks = diffMs / (1000 * 60 * 60 * 24 * 7);
|
||||||
|
return diffWeeks * cellWidth;
|
||||||
|
case "month":
|
||||||
|
// 월 단위는 일수가 다르므로 별도 계산
|
||||||
|
return calculateMonthPosition(date, viewStart, cellWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 충돌 감지
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function detectConflicts(schedules: ScheduleItem[], resourceId: string): ScheduleItem[][] {
|
||||||
|
const resourceSchedules = schedules
|
||||||
|
.filter(s => s.resourceId === resourceId)
|
||||||
|
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||||
|
|
||||||
|
const conflicts: ScheduleItem[][] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < resourceSchedules.length; i++) {
|
||||||
|
const current = resourceSchedules[i];
|
||||||
|
const overlapping = resourceSchedules.filter(s =>
|
||||||
|
s.id !== current.id &&
|
||||||
|
new Date(s.startDate) < new Date(current.endDate) &&
|
||||||
|
new Date(s.endDate) > new Date(current.startDate)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (overlapping.length > 0) {
|
||||||
|
conflicts.push([current, ...overlapping]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 구현 우선순위 및 일정
|
||||||
|
|
||||||
|
### 3.1 권장 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
1단계: v2-table-grouped (2-3일)
|
||||||
|
↓
|
||||||
|
2단계: v2-timeline-scheduler (5-7일)
|
||||||
|
↓
|
||||||
|
3단계: 생산계획관리 정식 버전 화면 구성 (1-2일)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 이유
|
||||||
|
|
||||||
|
1. **v2-table-grouped 먼저**:
|
||||||
|
- `v2-table-list` 기반 확장으로 난이도 낮음
|
||||||
|
- 생산계획 외 다른 화면(BOM, 수주 등)에서도 활용 가능
|
||||||
|
- 타임라인 개발 중에도 테스트용으로 사용 가능
|
||||||
|
|
||||||
|
2. **v2-timeline-scheduler 나중**:
|
||||||
|
- 복잡도가 높아 집중 개발 필요
|
||||||
|
- 드래그/리사이즈 등 인터랙션 테스트 필요
|
||||||
|
- 생산계획관리 전용 컴포넌트
|
||||||
|
|
||||||
|
### 3.3 체크리스트
|
||||||
|
|
||||||
|
#### v2-table-grouped ✅ 구현 완료 (2026-01-30)
|
||||||
|
|
||||||
|
- [x] 타입 정의 완료
|
||||||
|
- [x] 기본 구조 생성
|
||||||
|
- [x] useGroupedData 훅 구현
|
||||||
|
- [x] GroupHeader 컴포넌트
|
||||||
|
- [x] 메인 컴포넌트 구현
|
||||||
|
- [x] 그룹 체크박스 연동
|
||||||
|
- [x] 그룹 요약 (합계/개수)
|
||||||
|
- [x] 설정 패널 구현
|
||||||
|
- [x] 레지스트리 등록
|
||||||
|
- [x] 문서화 (README.md)
|
||||||
|
|
||||||
|
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30)
|
||||||
|
|
||||||
|
- [x] 타입 정의 완료
|
||||||
|
- [x] 기본 구조 생성
|
||||||
|
- [x] TimelineHeader (날짜)
|
||||||
|
- [x] TimelineGrid (배경)
|
||||||
|
- [x] ResourceColumn (리소스)
|
||||||
|
- [x] ScheduleBar 기본 렌더링
|
||||||
|
- [x] 드래그 이동 (기본)
|
||||||
|
- [x] 리사이즈 (기본)
|
||||||
|
- [x] 줌 레벨 전환
|
||||||
|
- [x] 날짜 네비게이션
|
||||||
|
- [ ] 충돌 감지 (향후)
|
||||||
|
- [ ] 가상 스크롤 (향후)
|
||||||
|
- [x] 설정 패널 구현
|
||||||
|
- [x] API 연동
|
||||||
|
- [x] 레지스트리 등록
|
||||||
|
- [ ] 테스트 완료
|
||||||
|
- [x] 문서화 (README.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 참고 자료
|
||||||
|
|
||||||
|
### 기존 V2 컴포넌트 참고
|
||||||
|
|
||||||
|
- `v2-table-list`: 테이블 렌더링, 체크박스, 페이지네이션
|
||||||
|
- `v2-pivot-grid`: 복잡한 그리드 렌더링, 가상 스크롤
|
||||||
|
- `v2-split-panel-layout`: 커스텀 모드 컴포넌트 배치
|
||||||
|
|
||||||
|
### 외부 라이브러리 검토
|
||||||
|
|
||||||
|
| 라이브러리 | 용도 | 고려 사항 |
|
||||||
|
|----------|------|----------|
|
||||||
|
| `@tanstack/react-virtual` | 가상 스크롤 | 이미 사용 중, 확장 용이 |
|
||||||
|
| `date-fns` | 날짜 계산 | 이미 사용 중 |
|
||||||
|
| `react-dnd` | 드래그앤드롭 | 검토 필요, 현재 네이티브 구현 |
|
||||||
|
|
||||||
|
### 관련 문서
|
||||||
|
|
||||||
|
- [생산계획관리 화면 설계](../03_production/production-plan.md)
|
||||||
|
- [V2 컴포넌트 분석 가이드](../../V2_컴포넌트_분석_가이드.md)
|
||||||
|
- [컴포넌트 개발 가이드](../../../frontend/docs/component-development-guide.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**작성자**: Claude AI
|
||||||
|
**최종 수정**: 2026-01-30
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
|
|
||||||
| ID | 이름 | 용도 | 주요 옵션 |
|
| ID | 이름 | 용도 | 주요 옵션 |
|
||||||
|----|------|------|----------|
|
|----|------|------|----------|
|
||||||
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, relation |
|
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, relation, **displayMode: custom** |
|
||||||
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs, activeTabId |
|
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs, activeTabId |
|
||||||
| `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding |
|
| `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding |
|
||||||
| `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow |
|
| `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow |
|
||||||
|
|
@ -284,6 +284,45 @@
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### v2-split-panel-layout 커스텀 모드 (NEW)
|
||||||
|
|
||||||
|
패널 내부에 자유롭게 컴포넌트를 배치할 수 있습니다. (v2-tabs-widget과 동일 구조)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
leftPanel: {
|
||||||
|
displayMode: "custom", // 커스텀 모드 활성화
|
||||||
|
components: [ // 내부 컴포넌트 배열
|
||||||
|
{
|
||||||
|
id: "btn-save",
|
||||||
|
componentType: "v2-button-primary",
|
||||||
|
label: "저장",
|
||||||
|
position: { x: 10, y: 10 },
|
||||||
|
size: { width: 100, height: 40 },
|
||||||
|
componentConfig: { buttonAction: "save" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tbl-list",
|
||||||
|
componentType: "v2-table-list",
|
||||||
|
label: "목록",
|
||||||
|
position: { x: 10, y: 60 },
|
||||||
|
size: { width: 400, height: 300 },
|
||||||
|
componentConfig: { selectedTable: "테이블명" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
rightPanel: {
|
||||||
|
displayMode: "table" // 기존 모드 유지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**디자인 모드 기능**:
|
||||||
|
- 컴포넌트 클릭 → 좌측 설정 패널에서 속성 편집
|
||||||
|
- 드래그 핸들(상단)로 이동
|
||||||
|
- 리사이즈 핸들(모서리)로 크기 조절
|
||||||
|
- 실제 컴포넌트 미리보기 렌더링
|
||||||
|
|
||||||
#### v2-card-display 필수 설정
|
#### v2-card-display 필수 설정
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
# 생산계획관리 (Production Plan Management)
|
# 생산계획관리 (Production Plan Management)
|
||||||
|
|
||||||
> ⚠️ **중요 안내**: 이 화면은 **복합형 레이아웃** (좌우 분할 패널 + 타임라인 스케줄러)으로, 현재 V2 컴포넌트만으로는 완전한 구현이 불가능합니다. 아래 문서는 화면 분석 및 향후 구현 계획을 위한 참조용입니다.
|
> **Screen ID**: /screens/{TBD}
|
||||||
|
> **메뉴 경로**: 생산관리 > 생산계획관리
|
||||||
|
|
||||||
|
> ⚠️ **구현 상태 안내**
|
||||||
|
> - **간소화 버전 (패턴 A)**: ✅ 즉시 구현 가능
|
||||||
|
> - **정식 버전 (복합 레이아웃)**: ⚠️ 컴포넌트 개발 후 구현 가능
|
||||||
|
> - ~~`v2-split-panel-layout` 커스텀 모드 확장~~ ✅ **구현 완료**
|
||||||
|
> - `v2-table-grouped`, `v2-timeline-scheduler` 신규 개발 (2개)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -10,16 +17,61 @@
|
||||||
|------|------|
|
|------|------|
|
||||||
| **화면명** | 생산계획관리 |
|
| **화면명** | 생산계획관리 |
|
||||||
| **영문명** | Production Plan Management |
|
| **영문명** | Production Plan Management |
|
||||||
| **화면 유형** | 복합형 (좌우 분할 패널 + 타임라인 스케줄러) |
|
| **Screen ID** | /screens/{TBD} |
|
||||||
|
| **메뉴 경로** | 생산관리 > 생산계획관리 |
|
||||||
|
| **화면 유형 (전체)** | 복합형 (좌우 분할 패널 + 타임라인 스케줄러) - ⚠️ 컴포넌트 확장 후 구현 |
|
||||||
|
| **화면 유형 (간소화)** | **패턴 A** (기본 마스터 화면) - ✅ 즉시 구현 가능 |
|
||||||
| **메인 테이블** | `production_plan_mng` |
|
| **메인 테이블** | `production_plan_mng` |
|
||||||
| **관련 테이블** | `sales_order_mng`, `item_info`, `equipment_info`, `bom_info` |
|
| **관련 테이블** | `sales_order_mng`, `item_info`, `equipment_info`, `bom_info` |
|
||||||
| **주요 기능** | 수주 기반 생산계획 수립, 타임라인 스케줄러, 자동 스케줄 생성, 반제품 계획 연동 |
|
| **주요 기능** | 수주 기반 생산계획 수립, 타임라인 스케줄러, 자동 스케줄 생성, 반제품 계획 연동 |
|
||||||
|
| **필요 작업** | ~~1. `v2-split-panel-layout` 확장~~ ✅ 완료 / 2. `v2-table-grouped` 개발 / 3. `v2-timeline-scheduler` 개발 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 화면 구조 분석
|
## 2. 테이블 선택 및 화면 구조
|
||||||
|
|
||||||
### 2.1 레이아웃 구조
|
### 2.1 사용 테이블
|
||||||
|
|
||||||
|
| 테이블명 | 용도 | 비고 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `production_plan_mng` | 생산계획 마스터 | 주 테이블 |
|
||||||
|
| `sales_order_mng` | 수주 정보 | 참조 (수주 기반 계획) |
|
||||||
|
| `item_info` | 품목 정보 | 참조 (품목코드, 품명) |
|
||||||
|
| `equipment_info` | 설비 정보 | 참조 (설비 할당) |
|
||||||
|
| `bom_info` | BOM 정보 | 참조 (반제품 계획 생성) |
|
||||||
|
|
||||||
|
### 2.2 테이블 관계
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┐ ┌──────────────────────┐
|
||||||
|
│ sales_order_mng │ │ production_plan_mng │
|
||||||
|
├──────────────────────┤ ├──────────────────────┤
|
||||||
|
│ order_no (PK) │──N:1──│ order_no (FK) │
|
||||||
|
│ item_code │ │ id (PK) │
|
||||||
|
│ ... │ │ plan_no │
|
||||||
|
└──────────────────────┘ │ item_code │
|
||||||
|
│ equipment_id (FK) │
|
||||||
|
┌──────────────────────┐ │ parent_plan_id (FK) │
|
||||||
|
│ item_info │ │ ... │
|
||||||
|
├──────────────────────┤ └──────────────────────┘
|
||||||
|
│ item_code (PK) │──1:N── │
|
||||||
|
│ item_name │ │
|
||||||
|
│ ... │ │
|
||||||
|
└──────────────────────┘ │
|
||||||
|
│
|
||||||
|
┌──────────────────────┐ │
|
||||||
|
│ equipment_info │ │
|
||||||
|
├──────────────────────┤ │
|
||||||
|
│ id (PK) │──1:N──────────┘
|
||||||
|
│ equipment_code │
|
||||||
|
│ equipment_name │
|
||||||
|
│ ... │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 화면 구조 분석 (전체 버전)
|
||||||
|
|
||||||
|
#### 2.3.1 레이아웃 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
|
@ -44,7 +96,7 @@
|
||||||
└────────────────────────────────┴──┴──────────────────────────────────────────┘
|
└────────────────────────────────┴──┴──────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2.2 탭 구조
|
#### 2.3.2 탭 구조
|
||||||
|
|
||||||
**왼쪽 패널 탭**:
|
**왼쪽 패널 탭**:
|
||||||
1. **수주데이터**: 수주 목록 (품목별 그룹핑)
|
1. **수주데이터**: 수주 목록 (품목별 그룹핑)
|
||||||
|
|
@ -117,54 +169,119 @@
|
||||||
| 기본 버튼 (엑셀, 사용자옵션) | ✅ | `v2-button-primary` | |
|
| 기본 버튼 (엑셀, 사용자옵션) | ✅ | `v2-button-primary` | |
|
||||||
| 단일 테이블 목록 | ✅ | `v2-table-list` | |
|
| 단일 테이블 목록 | ✅ | `v2-table-list` | |
|
||||||
| 기본 모달 폼 | ✅ | 모달 화면 | |
|
| 기본 모달 폼 | ✅ | 모달 화면 | |
|
||||||
| 좌우 분할 패널 (기본) | ⚠️ | `v2-split-panel-layout` | 테이블/리스트만 표시 가능 |
|
| 좌우 분할 패널 (커스텀 모드) | ✅ | `v2-split-panel-layout` | **displayMode: "custom" 지원** |
|
||||||
| 탭 컴포넌트 (기본) | ⚠️ | `v2-tabs-widget` | 디자인 모드에서 컴포넌트 배치 |
|
| 탭 컴포넌트 (기본) | ⚠️ | `v2-tabs-widget` | 디자인 모드에서 컴포넌트 배치 |
|
||||||
|
|
||||||
### 4.2 현재 V2 컴포넌트의 제한 사항
|
### 4.2 현재 V2 컴포넌트의 제한 사항 및 확장 방안
|
||||||
|
|
||||||
#### `v2-split-panel-layout` 제한
|
#### `v2-split-panel-layout` 현재 기능
|
||||||
|
|
||||||
**현재 기능**:
|
| 기능 | 지원 | 설명 |
|
||||||
- 좌우 분할 패널 (리사이즈 가능)
|
|------|:----:|------|
|
||||||
- 각 패널에 **테이블** 또는 **리스트** 표시 (`displayMode: "list" | "table"`)
|
| 좌우 분할 패널 | ✅ | 리사이즈 가능 |
|
||||||
- leftPanel ↔ rightPanel 관계 설정 (relation)
|
| 테이블/리스트 표시 | ✅ | `displayMode: "list" \| "table"` |
|
||||||
- 우측 패널에 추가 탭 (additionalTabs)
|
| 마스터-디테일 관계 | ✅ | `relation` 설정 |
|
||||||
|
| 우측 추가 탭 (`additionalTabs`) | ⚠️ | **타입만 정의, 렌더링 미구현** |
|
||||||
|
| **패널 내 임의 컴포넌트 배치** | ✅ | **`displayMode: "custom"` - 구현 완료!** |
|
||||||
|
|
||||||
**제한 사항**:
|
#### ✅ 커스텀 모드 (구현 완료)
|
||||||
- ❌ 패널 안에 **임의의 컴포넌트** (타임라인 등)를 배치할 수 없음
|
|
||||||
- ❌ **그룹화된 테이블** (접기/펼치기) 미지원
|
|
||||||
- ❌ 복잡한 커스텀 UI 배치 불가
|
|
||||||
|
|
||||||
#### `v2-tabs-widget` 제한
|
`v2-tabs-widget`과 동일한 구조로 패널 안에 컴포넌트를 자유롭게 배치:
|
||||||
|
|
||||||
**현재 기능**:
|
```typescript
|
||||||
- 탭별 컴포넌트 자유 배치
|
leftPanel: {
|
||||||
- 디자인 모드에서 드래그&드롭
|
displayMode: "list" | "table" | "custom", // ✅ 커스텀 모드 지원
|
||||||
|
|
||||||
|
// 기존 모드 (list, table)
|
||||||
|
tableName?: string,
|
||||||
|
columns?: [...],
|
||||||
|
|
||||||
|
// ✅ 커스텀 모드 - 패널 안에 자유롭게 컴포넌트 배치
|
||||||
|
components?: PanelInlineComponent[],
|
||||||
|
}
|
||||||
|
|
||||||
**제한 사항**:
|
// PanelInlineComponent 구조
|
||||||
- ❌ 탭 내에 **다른 V2 컴포넌트**를 완전히 자유롭게 배치하기 어려움
|
interface PanelInlineComponent {
|
||||||
- ❌ 런타임에서 복잡한 컴포넌트 렌더링 제한
|
id: string;
|
||||||
|
componentType: string;
|
||||||
|
label?: string;
|
||||||
|
position?: { x: number; y: number };
|
||||||
|
size?: { width: number; height: number };
|
||||||
|
componentConfig?: Record<string, any>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**커스텀 모드 기능**:
|
||||||
|
- ✅ 드래그앤드롭으로 컴포넌트 이동
|
||||||
|
- ✅ 리사이즈 핸들로 크기 조절
|
||||||
|
- ✅ 컴포넌트 클릭 시 좌측 설정 패널에서 속성 편집
|
||||||
|
- ✅ 디자인 모드에서 실제 컴포넌트 미리보기 렌더링
|
||||||
|
- ✅ 탭, 테이블, 버튼 등 모든 V2 컴포넌트 배치 가능
|
||||||
|
|
||||||
|
#### `v2-tabs-widget` 현재 기능
|
||||||
|
|
||||||
|
| 기능 | 지원 | 설명 |
|
||||||
|
|------|:----:|------|
|
||||||
|
| 탭별 컴포넌트 배치 | ✅ | `components[]` |
|
||||||
|
| 디자인 모드 드래그&드롭 | ✅ | 위치/크기 조정 |
|
||||||
|
| 런타임 렌더링 | ✅ | `DynamicComponentRenderer` 사용 |
|
||||||
|
|
||||||
|
**`v2-tabs-widget` 구조** (참고용):
|
||||||
|
```typescript
|
||||||
|
interface TabItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
components?: TabInlineComponent[]; // 탭 안에 배치된 컴포넌트들
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabInlineComponent {
|
||||||
|
id: string;
|
||||||
|
componentType: string; // "v2-table-list", "v2-timeline-scheduler" 등
|
||||||
|
position?: { x: number; y: number };
|
||||||
|
size?: { width: number; height: number };
|
||||||
|
componentConfig?: Record<string, any>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### 4.3 생산계획관리에 필요한 기능 vs 현재 지원
|
### 4.3 생산계획관리에 필요한 기능 vs 현재 지원
|
||||||
|
|
||||||
| 필요 기능 | 현재 지원 | 설명 |
|
| 필요 기능 | 현재 지원 | 해결 방안 |
|
||||||
|----------|:---------:|------|
|
|----------|:---------:|----------|
|
||||||
| 좌우 분할 패널 | ⚠️ 부분 | `v2-split-panel-layout` - 테이블만 가능 |
|
| 좌우 분할 패널 | ✅ | `v2-split-panel-layout` |
|
||||||
| 왼쪽 패널 탭 (수주/안전재고) | ❌ | 분할 패널 내 탭 조합 미지원 |
|
| 왼쪽 패널 탭 (수주/안전재고) | ✅ | **커스텀 모드 + `v2-tabs-widget` 조합 (구현 완료)** |
|
||||||
| **그룹화된 테이블** (품목별 접기/펼치기) | ❌ | 신규 개발 필요 |
|
| 오른쪽 패널 탭 (완제품/반제품) | ✅ | **커스텀 모드 + `v2-tabs-widget` 조합 (구현 완료)** |
|
||||||
| 오른쪽 패널 탭 (완제품/반제품) | ❌ | 분할 패널 내 탭 조합 미지원 |
|
| **그룹화된 테이블** (품목별 접기/펼치기) | ❌ | **신규 개발 필요** |
|
||||||
| **타임라인 스케줄러** (간트차트) | ❌ | 신규 개발 필요 |
|
| **타임라인 스케줄러** (간트차트) | ❌ | **신규 개발 필요** |
|
||||||
| 드래그&드롭 스케줄 이동 | ❌ | 신규 개발 필요 |
|
| 드래그&드롭 스케줄 이동 | ❌ | `v2-timeline-scheduler`에 포함 |
|
||||||
| 복잡한 상세 모달 (분할, 설비할당) | ❌ | 커스텀 모달 개발 필요 |
|
| 복잡한 상세 모달 | ✅ | 기존 모달 화면으로 충분 |
|
||||||
|
|
||||||
### 4.4 향후 개발 필요 컴포넌트
|
### 4.4 향후 개발 필요 사항
|
||||||
|
|
||||||
|
#### 기존 컴포넌트 확장 (1개)
|
||||||
|
|
||||||
|
| 컴포넌트 | 작업 내용 | 상태 |
|
||||||
|
|----------|----------|:------:|
|
||||||
|
| `v2-split-panel-layout` | `displayMode: "custom"` 추가 + `components[]` 지원 | ✅ **완료** |
|
||||||
|
|
||||||
|
#### 신규 컴포넌트 개발 (2개) - 대체 불가
|
||||||
|
|
||||||
|
| 컴포넌트 | 필요 이유 | 상태 |
|
||||||
|
|----------|----------|:------:|
|
||||||
|
| ~~**`v2-table-grouped`**~~ | 품목별 접기/펼치기 그룹화 테이블 | ✅ **완료** |
|
||||||
|
| **`v2-timeline-scheduler`** | 간트차트 형태의 타임라인 스케줄러 | ⏳ 대기 |
|
||||||
|
|
||||||
|
#### 불필요한 컴포넌트 (이전 분석에서 제외)
|
||||||
|
|
||||||
|
| 컴포넌트 | 제외 이유 |
|
||||||
|
|----------|----------|
|
||||||
|
| ~~`v2-split-panel-enhanced`~~ | 커스텀 모드로 대체 가능 |
|
||||||
|
| ~~`v2-modal-complex`~~ | 기존 모달 화면으로 충분 |
|
||||||
|
|
||||||
```
|
```
|
||||||
필요 컴포넌트 목록:
|
최종 필요 작업:
|
||||||
1. v2-timeline-scheduler - 타임라인/간트차트 스케줄러 (핵심!)
|
1. ✅ v2-split-panel-layout 확장 - displayMode: "custom" 추가 [완료]
|
||||||
2. v2-table-grouped - 그룹화된 테이블 (접기/펼치기)
|
2. ✅ v2-table-grouped 신규 개발 - 그룹화 테이블 (접기/펼치기) [완료]
|
||||||
3. v2-split-panel-enhanced - 패널 내 임의 컴포넌트 배치 가능한 확장판
|
3. v2-timeline-scheduler 신규 개발 - 타임라인/간트차트 (핵심!)
|
||||||
4. v2-modal-complex - 복잡한 모달 (분할, 다단계, 설비할당)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.5 현재 구현 가능한 최대 범위
|
### 4.5 현재 구현 가능한 최대 범위
|
||||||
|
|
@ -189,6 +306,63 @@
|
||||||
- 자동 스케줄 생성
|
- 자동 스케줄 생성
|
||||||
- 드래그로 스케줄 이동
|
- 드래그로 스케줄 이동
|
||||||
|
|
||||||
|
### 4.6 정식 버전 구현 구조 (확장 후)
|
||||||
|
|
||||||
|
`v2-split-panel-layout`에 커스텀 모드를 추가한 후의 **정식 버전 구현 구조**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ v2-split-panel-layout (양쪽 패널 displayMode: "custom") │
|
||||||
|
├────────────────────────────────┬─────────────────────────────────────┤
|
||||||
|
│ leftPanel.components: [ │ rightPanel.components: [ │
|
||||||
|
│ v2-tabs-widget { │ v2-tabs-widget { │
|
||||||
|
│ tabs: [ │ tabs: [ │
|
||||||
|
│ { │ { │
|
||||||
|
│ label: "수주데이터", │ label: "완제품 생산계획", │
|
||||||
|
│ components: [ │ components: [ │
|
||||||
|
│ v2-table-grouped │ v2-timeline-scheduler │
|
||||||
|
│ ] │ ] │
|
||||||
|
│ }, │ }, │
|
||||||
|
│ { │ { │
|
||||||
|
│ label: "안전재고부족", │ label: "반제품 생산계획", │
|
||||||
|
│ components: [ │ components: [ │
|
||||||
|
│ v2-table-grouped │ v2-timeline-scheduler │
|
||||||
|
│ ] │ ] │
|
||||||
|
│ } │ } │
|
||||||
|
│ ] │ ] │
|
||||||
|
│ } │ } │
|
||||||
|
│ ] │ ] │
|
||||||
|
└────────────────────────────────┴─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**컴포넌트 중첩 구조**:
|
||||||
|
```
|
||||||
|
v2-split-panel-layout (displayMode: "custom")
|
||||||
|
├── leftPanel.components[]
|
||||||
|
│ └── v2-tabs-widget
|
||||||
|
│ ├── tabs[0]: "수주데이터"
|
||||||
|
│ │ └── components[]
|
||||||
|
│ │ └── v2-table-grouped (품목별 그룹화)
|
||||||
|
│ └── tabs[1]: "안전재고부족"
|
||||||
|
│ └── components[]
|
||||||
|
│ └── v2-table-grouped
|
||||||
|
│
|
||||||
|
└── rightPanel.components[]
|
||||||
|
└── v2-tabs-widget
|
||||||
|
├── tabs[0]: "완제품 생산계획"
|
||||||
|
│ └── components[]
|
||||||
|
│ └── v2-timeline-scheduler (간트차트)
|
||||||
|
└── tabs[1]: "반제품 생산계획"
|
||||||
|
└── components[]
|
||||||
|
└── v2-timeline-scheduler
|
||||||
|
```
|
||||||
|
|
||||||
|
**장점**:
|
||||||
|
- 기존 `v2-tabs-widget` 로직 100% 재사용
|
||||||
|
- 패널 안에 어떤 컴포넌트든 배치 가능
|
||||||
|
- 별도 `v2-split-panel-enhanced` 개발 불필요
|
||||||
|
- 확장성 뛰어남 (향후 다른 복합 화면에도 적용 가능)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 단순화된 구현 방안 (임시)
|
## 5. 단순화된 구현 방안 (임시)
|
||||||
|
|
@ -211,42 +385,107 @@
|
||||||
└──────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5.2 간소화 버전 테이블 컬럼
|
### 5.2 컴포넌트 배치도 (간소화 버전)
|
||||||
|
|
||||||
| 순서 | 컬럼명 | 표시명 | 정렬 | 형식 |
|
```
|
||||||
|:----:|--------|--------|:----:|------|
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
| 1 | plan_no | 계획번호 | left | text |
|
│ v2-table-search-widget (comp_search) │
|
||||||
| 2 | plan_date | 계획일자 | center | date |
|
│ [품목코드] [품명] [계획기간: 시작~종료] [상태] [조회] [엑셀다운로드] │
|
||||||
| 3 | item_code | 품목코드 | left | text |
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
| 4 | item_name | 품명 | left | text |
|
│ 생산계획 목록 [신규등록] [수정] [삭제] [작업지시 생성] │
|
||||||
| 5 | plan_qty | 계획수량 | right | number |
|
│ ─────────────────────────────────────────────────────────────────────────── │
|
||||||
| 6 | start_date | 시작일 | center | date |
|
│ v2-table-list (comp_table) │
|
||||||
| 7 | end_date | 종료일 | center | date |
|
│ □ | 계획번호 | 계획일자 | 품목코드 | 품명 | 계획수량 | 시작일 | 종료일 | ... │
|
||||||
| 8 | due_date | 납기일 | center | date |
|
│ □ | PP-001 | 2026-01 | ITEM001 | 제품A| 1,000 | 01-30 | 02-05 | ... │
|
||||||
| 9 | equipment_name | 설비 | left | text |
|
│ □ | PP-002 | 2026-01 | ITEM002 | 제품B| 500 | 02-01 | 02-10 | ... │
|
||||||
| 10 | status | 상태 | center | badge |
|
│ │
|
||||||
| 11 | manager_name | 담당자 | left | text |
|
│ [1] [2] [3] ... [10] 페이지당: [20] ▼ │
|
||||||
| 12 | product_type | 제품유형 | center | text |
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
### 5.3 간소화 버전 모달 필드
|
### 5.3 컴포넌트 목록 (간소화 버전)
|
||||||
|
|
||||||
| 필드명 | 라벨 | 타입 | 필수 |
|
| 컴포넌트 ID | 컴포넌트 타입 | 역할 |
|
||||||
|--------|------|------|:----:|
|
|-------------|---------------|------|
|
||||||
| plan_no | 계획번호 | text | ✅ |
|
| `comp_search` | v2-table-search-widget | 검색 필터 |
|
||||||
| plan_date | 계획일자 | date | ✅ |
|
| `comp_table` | v2-table-list | 생산계획 목록 |
|
||||||
| item_code | 품목코드 | select (품목 검색) | ✅ |
|
| `comp_btn_register` | v2-button-primary | 신규 등록 |
|
||||||
| item_name | 품명 | text (자동) | |
|
| `comp_btn_edit` | v2-button-primary | 수정 |
|
||||||
| plan_qty | 계획수량 | number | ✅ |
|
| `comp_btn_delete` | v2-button-primary | 삭제 |
|
||||||
| start_date | 시작일 | date | ✅ |
|
| `comp_btn_work_order` | v2-button-primary | 작업지시 생성 |
|
||||||
| end_date | 종료일 | date | ✅ |
|
|
||||||
| due_date | 납기일 | date | |
|
### 5.4 간소화 버전 테이블 컬럼
|
||||||
| equipment_id | 설비 | select | |
|
|
||||||
| status | 상태 | select | ✅ |
|
| 순서 | 컬럼명 | 표시명 | 정렬 | 형식 | 너비 |
|
||||||
| priority | 우선순위 | select | |
|
|:----:|--------|--------|:----:|------|:----:|
|
||||||
| work_shift | 작업조 | select | |
|
| 1 | plan_no | 계획번호 | left | text | 120 |
|
||||||
| manager_name | 담당자 | text | |
|
| 2 | plan_date | 계획일자 | center | date | 100 |
|
||||||
| remarks | 비고 | textarea | |
|
| 3 | item_code | 품목코드 | left | text | 100 |
|
||||||
| product_type | 제품유형 | select | |
|
| 4 | item_name | 품명 | left | text | 150 |
|
||||||
|
| 5 | plan_qty | 계획수량 | right | number | 100 |
|
||||||
|
| 6 | start_date | 시작일 | center | date | 100 |
|
||||||
|
| 7 | end_date | 종료일 | center | date | 100 |
|
||||||
|
| 8 | due_date | 납기일 | center | date | 100 |
|
||||||
|
| 9 | equipment_name | 설비 | left | text | 120 |
|
||||||
|
| 10 | status | 상태 | center | badge | 80 |
|
||||||
|
| 11 | manager_name | 담당자 | left | text | 100 |
|
||||||
|
| 12 | product_type | 제품유형 | center | text | 80 |
|
||||||
|
|
||||||
|
### 5.5 간소화 버전 모달 필드
|
||||||
|
|
||||||
|
| 필드명 | 라벨 | 타입 | 필수 | 비고 |
|
||||||
|
|--------|------|------|:----:|------|
|
||||||
|
| plan_no | 계획번호 | text (readonly) | ✅ | 자동채번: PP-YYYYMMDD-0001 |
|
||||||
|
| plan_date | 계획일자 | date | ✅ | 기본값: 오늘 |
|
||||||
|
| item_code | 품목코드 | select (품목 검색) | ✅ | 엔티티: item_info |
|
||||||
|
| item_name | 품명 | text (readonly) | | 자동 입력 |
|
||||||
|
| plan_qty | 계획수량 | number | ✅ | min: 1 |
|
||||||
|
| start_date | 시작일 | date | ✅ | |
|
||||||
|
| end_date | 종료일 | date | ✅ | start_date 이후 |
|
||||||
|
| due_date | 납기일 | date | | |
|
||||||
|
| equipment_id | 설비 | select | | 엔티티: equipment_info |
|
||||||
|
| equipment_name | 설비명 | text (readonly) | | 자동 입력 |
|
||||||
|
| status | 상태 | select | ✅ | 공통코드: PROD_PLAN_STATUS |
|
||||||
|
| priority | 우선순위 | select | | 공통코드: PRIORITY |
|
||||||
|
| work_shift | 작업조 | select | | 공통코드: WORK_SHIFT |
|
||||||
|
| manager_name | 담당자 | text | | |
|
||||||
|
| product_type | 제품유형 | select | | 공통코드: PRODUCT_TYPE |
|
||||||
|
| remarks | 비고 | textarea | | |
|
||||||
|
|
||||||
|
### 5.6 상태 배지 및 공통코드
|
||||||
|
|
||||||
|
#### 상태 (PROD_PLAN_STATUS)
|
||||||
|
|
||||||
|
| 코드 | 표시명 | 배경색 | 글자색 |
|
||||||
|
|------|--------|--------|--------|
|
||||||
|
| 계획 | 계획 | #dbeafe | #1e40af |
|
||||||
|
| 지시 | 지시 | #fef3c7 | #92400e |
|
||||||
|
| 진행 | 진행 | #d1fae5 | #065f46 |
|
||||||
|
| 완료 | 완료 | #f3f4f6 | #4b5563 |
|
||||||
|
|
||||||
|
#### 우선순위 (PRIORITY)
|
||||||
|
|
||||||
|
| 코드 | 표시명 |
|
||||||
|
|------|--------|
|
||||||
|
| 긴급 | 긴급 |
|
||||||
|
| 높음 | 높음 |
|
||||||
|
| 보통 | 보통 |
|
||||||
|
| 낮음 | 낮음 |
|
||||||
|
|
||||||
|
#### 작업조 (WORK_SHIFT)
|
||||||
|
|
||||||
|
| 코드 | 표시명 |
|
||||||
|
|------|--------|
|
||||||
|
| 주간 | 주간 |
|
||||||
|
| 야간 | 야간 |
|
||||||
|
| 주야 | 주야 |
|
||||||
|
|
||||||
|
#### 제품유형 (PRODUCT_TYPE)
|
||||||
|
|
||||||
|
| 코드 | 표시명 |
|
||||||
|
|------|--------|
|
||||||
|
| 완제품 | 완제품 |
|
||||||
|
| 반제품 | 반제품 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -395,18 +634,35 @@ V2 컴포넌트로 기본 CRUD 화면 구현:
|
||||||
- 기본 모달 폼
|
- 기본 모달 폼
|
||||||
- 상태 배지
|
- 상태 배지
|
||||||
|
|
||||||
### Phase 2: 컴포넌트 개발 후
|
### Phase 2: 컴포넌트 확장 및 개발
|
||||||
|
|
||||||
1. `v2-tabs` 컴포넌트 개발
|
1. ~~**`v2-split-panel-layout` 확장**~~ ✅ **완료**
|
||||||
2. `v2-split-panel` 컴포넌트 개발
|
- ✅ `displayMode: "custom"` 추가
|
||||||
3. `v2-table-grouped` 컴포넌트 개발
|
- ✅ `components[]` 필드 지원 (탭 컴포넌트와 동일한 구조)
|
||||||
|
- ✅ 디자인 모드에서 컴포넌트 드래그&드롭 지원
|
||||||
|
- ✅ 컴포넌트 클릭 시 좌측 설정 패널에서 속성 편집
|
||||||
|
|
||||||
|
2. ~~**`v2-table-grouped` 신규 개발**~~ ✅ **완료**
|
||||||
|
- ✅ 품목별 그룹핑 (접기/펼치기)
|
||||||
|
- ✅ 그룹 행과 상세 행 구분
|
||||||
|
- ✅ 체크박스 선택 (그룹 단위 / 개별 단위)
|
||||||
|
- ✅ 그룹 요약 (개수, 합계, 평균 등)
|
||||||
|
|
||||||
### Phase 3: 타임라인 스케줄러
|
### Phase 3: 타임라인 스케줄러
|
||||||
|
|
||||||
1. `v2-timeline-scheduler` 컴포넌트 개발
|
1. **`v2-timeline-scheduler` 신규 개발**
|
||||||
2. 드래그&드롭 기능
|
- 간트차트 형태 타임라인
|
||||||
3. 자동 스케줄 생성 로직
|
- 날짜 그리드 (일/주/월 단위)
|
||||||
4. 반제품 연동
|
- 생산계획 바 렌더링
|
||||||
|
|
||||||
|
2. **드래그&드롭 기능**
|
||||||
|
- 계획 바 드래그로 날짜 이동
|
||||||
|
- 계획 바 크기 조정 (기간 변경)
|
||||||
|
|
||||||
|
3. **비즈니스 로직**
|
||||||
|
- 자동 스케줄 생성 API
|
||||||
|
- BOM 기반 반제품 계획 연동
|
||||||
|
- 설비별 용량 체크
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -488,7 +744,25 @@ V2 컴포넌트로 기본 CRUD 화면 구현:
|
||||||
"position": { "x": 0, "y": 20, "z": 1 },
|
"position": { "x": 0, "y": 20, "z": 1 },
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"type": "v2-table-search-widget",
|
"type": "v2-table-search-widget",
|
||||||
"label": "검색 필터"
|
"label": "검색 필터",
|
||||||
|
"searchFields": [
|
||||||
|
{ "field": "item_code", "label": "품목코드", "type": "text" },
|
||||||
|
{ "field": "item_name", "label": "품명", "type": "text" },
|
||||||
|
{ "field": "plan_date", "label": "계획기간", "type": "daterange" },
|
||||||
|
{
|
||||||
|
"field": "status",
|
||||||
|
"label": "상태",
|
||||||
|
"type": "select",
|
||||||
|
"options": [
|
||||||
|
{ "value": "", "label": "전체" },
|
||||||
|
{ "value": "계획", "label": "계획" },
|
||||||
|
{ "value": "지시", "label": "지시" },
|
||||||
|
{ "value": "진행", "label": "진행" },
|
||||||
|
{ "value": "완료", "label": "완료" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"targetTableId": "comp_table"
|
||||||
},
|
},
|
||||||
"displayOrder": 0
|
"displayOrder": 0
|
||||||
},
|
},
|
||||||
|
|
@ -501,23 +775,39 @@ V2 컴포넌트로 기본 CRUD 화면 구현:
|
||||||
"type": "v2-table-list",
|
"type": "v2-table-list",
|
||||||
"label": "생산계획 목록",
|
"label": "생산계획 목록",
|
||||||
"columns": [
|
"columns": [
|
||||||
{ "columnName": "plan_no", "displayName": "계획번호", "order": 0, "visible": true, "sortable": true, "format": "text", "align": "left" },
|
{ "columnName": "plan_no", "displayName": "계획번호", "order": 0, "visible": true, "sortable": true, "format": "text", "align": "left", "width": 120 },
|
||||||
{ "columnName": "plan_date", "displayName": "계획일자", "order": 1, "visible": true, "sortable": true, "format": "date", "align": "center" },
|
{ "columnName": "plan_date", "displayName": "계획일자", "order": 1, "visible": true, "sortable": true, "format": "date", "align": "center", "width": 100 },
|
||||||
{ "columnName": "item_code", "displayName": "품목코드", "order": 2, "visible": true, "sortable": true, "format": "text", "align": "left" },
|
{ "columnName": "item_code", "displayName": "품목코드", "order": 2, "visible": true, "sortable": true, "format": "text", "align": "left", "width": 100 },
|
||||||
{ "columnName": "item_name", "displayName": "품명", "order": 3, "visible": true, "sortable": true, "format": "text", "align": "left" },
|
{ "columnName": "item_name", "displayName": "품명", "order": 3, "visible": true, "sortable": true, "format": "text", "align": "left", "width": 150 },
|
||||||
{ "columnName": "plan_qty", "displayName": "계획수량", "order": 4, "visible": true, "sortable": true, "format": "number", "align": "right" },
|
{ "columnName": "plan_qty", "displayName": "계획수량", "order": 4, "visible": true, "sortable": true, "format": "number", "align": "right", "width": 100 },
|
||||||
{ "columnName": "start_date", "displayName": "시작일", "order": 5, "visible": true, "sortable": true, "format": "date", "align": "center" },
|
{ "columnName": "start_date", "displayName": "시작일", "order": 5, "visible": true, "sortable": true, "format": "date", "align": "center", "width": 100 },
|
||||||
{ "columnName": "end_date", "displayName": "종료일", "order": 6, "visible": true, "sortable": true, "format": "date", "align": "center" },
|
{ "columnName": "end_date", "displayName": "종료일", "order": 6, "visible": true, "sortable": true, "format": "date", "align": "center", "width": 100 },
|
||||||
{ "columnName": "due_date", "displayName": "납기일", "order": 7, "visible": true, "sortable": true, "format": "date", "align": "center" },
|
{ "columnName": "due_date", "displayName": "납기일", "order": 7, "visible": true, "sortable": true, "format": "date", "align": "center", "width": 100 },
|
||||||
{ "columnName": "equipment_name", "displayName": "설비", "order": 8, "visible": true, "sortable": true, "format": "text", "align": "left" },
|
{ "columnName": "equipment_name", "displayName": "설비", "order": 8, "visible": true, "sortable": true, "format": "text", "align": "left", "width": 120 },
|
||||||
{ "columnName": "status", "displayName": "상태", "order": 9, "visible": true, "sortable": true, "format": "text", "align": "center" },
|
{
|
||||||
{ "columnName": "product_type", "displayName": "제품유형", "order": 10, "visible": true, "sortable": true, "format": "text", "align": "center" },
|
"columnName": "status",
|
||||||
{ "columnName": "manager_name", "displayName": "담당자", "order": 11, "visible": true, "sortable": true, "format": "text", "align": "left" }
|
"displayName": "상태",
|
||||||
|
"order": 9,
|
||||||
|
"visible": true,
|
||||||
|
"sortable": true,
|
||||||
|
"format": "badge",
|
||||||
|
"align": "center",
|
||||||
|
"width": 80,
|
||||||
|
"badgeConfig": {
|
||||||
|
"계획": { "background": "#dbeafe", "color": "#1e40af" },
|
||||||
|
"지시": { "background": "#fef3c7", "color": "#92400e" },
|
||||||
|
"진행": { "background": "#d1fae5", "color": "#065f46" },
|
||||||
|
"완료": { "background": "#f3f4f6", "color": "#4b5563" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ "columnName": "product_type", "displayName": "제품유형", "order": 10, "visible": true, "sortable": true, "format": "text", "align": "center", "width": 80 },
|
||||||
|
{ "columnName": "manager_name", "displayName": "담당자", "order": 11, "visible": true, "sortable": true, "format": "text", "align": "left", "width": 100 }
|
||||||
],
|
],
|
||||||
"selectedTable": "production_plan_mng",
|
"selectedTable": "production_plan_mng",
|
||||||
"pagination": { "enabled": true, "pageSize": 20, "pageSizeOptions": [10, 20, 50, 100] },
|
"pagination": { "enabled": true, "pageSize": 20, "pageSizeOptions": [10, 20, 50, 100] },
|
||||||
"checkbox": { "enabled": true, "multiple": true },
|
"checkbox": { "enabled": true, "multiple": true },
|
||||||
"horizontalScroll": { "enabled": true, "minColumnWidth": 80, "maxColumnWidth": 200 }
|
"horizontalScroll": { "enabled": true, "minColumnWidth": 80, "maxColumnWidth": 200 },
|
||||||
|
"defaultSort": { "field": "plan_date", "order": "desc" }
|
||||||
},
|
},
|
||||||
"displayOrder": 1
|
"displayOrder": 1
|
||||||
},
|
},
|
||||||
|
|
@ -525,7 +815,7 @@ V2 컴포넌트로 기본 CRUD 화면 구현:
|
||||||
"id": "comp_btn_register",
|
"id": "comp_btn_register",
|
||||||
"url": "@/lib/registry/components/v2-button-primary",
|
"url": "@/lib/registry/components/v2-button-primary",
|
||||||
"size": { "width": 100, "height": 40 },
|
"size": { "width": 100, "height": 40 },
|
||||||
"position": { "x": 1580, "y": 70, "z": 1 },
|
"position": { "x": 1480, "y": 70, "z": 1 },
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"text": "신규 등록",
|
"text": "신규 등록",
|
||||||
"type": "v2-button-primary",
|
"type": "v2-button-primary",
|
||||||
|
|
@ -538,12 +828,13 @@ V2 컴포넌트로 기본 CRUD 화면 구현:
|
||||||
"id": "comp_btn_edit",
|
"id": "comp_btn_edit",
|
||||||
"url": "@/lib/registry/components/v2-button-primary",
|
"url": "@/lib/registry/components/v2-button-primary",
|
||||||
"size": { "width": 80, "height": 40 },
|
"size": { "width": 80, "height": 40 },
|
||||||
"position": { "x": 1690, "y": 70, "z": 1 },
|
"position": { "x": 1590, "y": 70, "z": 1 },
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"text": "수정",
|
"text": "수정",
|
||||||
"type": "v2-button-primary",
|
"type": "v2-button-primary",
|
||||||
"action": { "type": "edit", "modalTitle": "생산계획 수정", "targetScreenId": null },
|
"action": { "type": "edit", "modalTitle": "생산계획 수정", "targetScreenId": null },
|
||||||
"variant": "secondary"
|
"variant": "secondary",
|
||||||
|
"requireSelection": true
|
||||||
},
|
},
|
||||||
"displayOrder": 3
|
"displayOrder": 3
|
||||||
},
|
},
|
||||||
|
|
@ -551,14 +842,34 @@ V2 컴포넌트로 기본 CRUD 화면 구현:
|
||||||
"id": "comp_btn_delete",
|
"id": "comp_btn_delete",
|
||||||
"url": "@/lib/registry/components/v2-button-primary",
|
"url": "@/lib/registry/components/v2-button-primary",
|
||||||
"size": { "width": 80, "height": 40 },
|
"size": { "width": 80, "height": 40 },
|
||||||
"position": { "x": 1780, "y": 70, "z": 1 },
|
"position": { "x": 1680, "y": 70, "z": 1 },
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"text": "삭제",
|
"text": "삭제",
|
||||||
"type": "v2-button-primary",
|
"type": "v2-button-primary",
|
||||||
"action": { "type": "delete" },
|
"action": { "type": "delete" },
|
||||||
"variant": "danger"
|
"variant": "danger",
|
||||||
|
"requireSelection": true,
|
||||||
|
"enableDataflowControl": true,
|
||||||
|
"dataflowDiagramId": null
|
||||||
},
|
},
|
||||||
"displayOrder": 4
|
"displayOrder": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "comp_btn_work_order",
|
||||||
|
"url": "@/lib/registry/components/v2-button-primary",
|
||||||
|
"size": { "width": 120, "height": 40 },
|
||||||
|
"position": { "x": 1770, "y": 70, "z": 1 },
|
||||||
|
"overrides": {
|
||||||
|
"text": "작업지시 생성",
|
||||||
|
"type": "v2-button-primary",
|
||||||
|
"action": { "type": "custom" },
|
||||||
|
"variant": "primary",
|
||||||
|
"requireSelection": true,
|
||||||
|
"enableDataflowControl": true,
|
||||||
|
"dataflowDiagramId": null,
|
||||||
|
"confirmMessage": "선택한 계획에 대해 작업지시를 생성하시겠습니까?"
|
||||||
|
},
|
||||||
|
"displayOrder": 5
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -566,9 +877,312 @@ V2 컴포넌트로 기본 CRUD 화면 구현:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. 구현 체크리스트
|
## 10. 비즈니스 로직 요청서 (간소화 버전)
|
||||||
|
|
||||||
### 10.1 간소화 버전 (현재 구현 가능)
|
> **중요**: 이 섹션은 표준 양식에 따라 작성되었습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
=== 비즈니스 로직 요청서 ===
|
||||||
|
|
||||||
|
【화면 정보】
|
||||||
|
- 화면명: 생산계획관리 (간소화 버전)
|
||||||
|
- 회사코드: {COMPANY_CODE}
|
||||||
|
- 메뉴ID: {TBD}
|
||||||
|
|
||||||
|
【테이블 정보】
|
||||||
|
- 메인 테이블: production_plan_mng
|
||||||
|
- 디테일 테이블: 없음 (간소화 버전)
|
||||||
|
- 관계 FK: 없음
|
||||||
|
|
||||||
|
【버튼 목록】
|
||||||
|
버튼1:
|
||||||
|
- 버튼명: 신규 등록
|
||||||
|
- 동작 유형: 모달 열기 (insert)
|
||||||
|
- 조건: 없음
|
||||||
|
- 대상 테이블: production_plan_mng
|
||||||
|
- 추가 동작:
|
||||||
|
1. plan_no 자동채번 (PP-YYYYMMDD-0001)
|
||||||
|
2. plan_date 기본값 = 오늘
|
||||||
|
3. status 기본값 = '계획'
|
||||||
|
|
||||||
|
버튼2:
|
||||||
|
- 버튼명: 수정
|
||||||
|
- 동작 유형: 모달 열기 (update)
|
||||||
|
- 조건: 행 선택 필수
|
||||||
|
- 대상 테이블: production_plan_mng
|
||||||
|
- 추가 동작: 없음
|
||||||
|
|
||||||
|
버튼3:
|
||||||
|
- 버튼명: 삭제
|
||||||
|
- 동작 유형: 삭제 (delete)
|
||||||
|
- 조건:
|
||||||
|
1. 행 선택 필수
|
||||||
|
2. status != '진행' AND status != '완료'
|
||||||
|
- 대상 테이블: production_plan_mng
|
||||||
|
- 추가 동작: 삭제 확인 다이얼로그
|
||||||
|
|
||||||
|
버튼4:
|
||||||
|
- 버튼명: 작업지시 생성
|
||||||
|
- 동작 유형: 수정 (update)
|
||||||
|
- 조건:
|
||||||
|
1. 행 선택 필수 (다중 선택 가능)
|
||||||
|
2. status = '계획'
|
||||||
|
- 대상 테이블: production_plan_mng
|
||||||
|
- 추가 동작:
|
||||||
|
1. status를 '지시'로 변경
|
||||||
|
2. work_order_no 자동채번 (WO-YYYYMMDD-0001)
|
||||||
|
3. work_order_mng에 INSERT (작업지시 테이블)
|
||||||
|
|
||||||
|
【추가 요구사항】
|
||||||
|
- 진행/완료 상태의 계획은 수정/삭제 불가
|
||||||
|
- 계획번호(plan_no) 자동채번: PP-YYYYMMDD-0001
|
||||||
|
- 품목코드 선택 시 품명 자동 입력
|
||||||
|
- 설비 선택 시 시간당 생산능력 자동 입력
|
||||||
|
- 시작일/종료일 기반 생산기간 자동 계산
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 제어관리 설정 (dataflow_diagrams)
|
||||||
|
|
||||||
|
### 11.1 신규 등록 버튼 (기본 저장)
|
||||||
|
|
||||||
|
> 기본 INSERT 액션은 화면 디자이너에서 처리되므로 별도 dataflow 불필요
|
||||||
|
|
||||||
|
### 11.2 삭제 버튼 (조건부 삭제)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"diagram_name": "생산계획관리_삭제",
|
||||||
|
"company_code": "{COMPANY_CODE}",
|
||||||
|
"control": {
|
||||||
|
"actionType": "delete",
|
||||||
|
"triggerType": "before",
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "cond-status-not-in-progress",
|
||||||
|
"type": "condition",
|
||||||
|
"field": "status",
|
||||||
|
"operator": "!=",
|
||||||
|
"value": "진행",
|
||||||
|
"dataType": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cond-status-not-completed",
|
||||||
|
"type": "condition",
|
||||||
|
"field": "status",
|
||||||
|
"operator": "!=",
|
||||||
|
"value": "완료",
|
||||||
|
"dataType": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"conditionLogic": "AND",
|
||||||
|
"errorMessage": "진행 중이거나 완료된 계획은 삭제할 수 없습니다."
|
||||||
|
},
|
||||||
|
"plan": {
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "action-delete",
|
||||||
|
"actionType": "delete",
|
||||||
|
"targetTable": "production_plan_mng"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 작업지시 생성 버튼
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"diagram_name": "생산계획관리_작업지시생성",
|
||||||
|
"company_code": "{COMPANY_CODE}",
|
||||||
|
"control": {
|
||||||
|
"actionType": "update",
|
||||||
|
"triggerType": "after",
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "cond-status-plan",
|
||||||
|
"type": "condition",
|
||||||
|
"field": "status",
|
||||||
|
"operator": "=",
|
||||||
|
"value": "계획",
|
||||||
|
"dataType": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errorMessage": "계획 상태인 항목만 작업지시를 생성할 수 있습니다."
|
||||||
|
},
|
||||||
|
"plan": {
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "action-update-status",
|
||||||
|
"actionType": "update",
|
||||||
|
"targetTable": "production_plan_mng",
|
||||||
|
"fieldMappings": [
|
||||||
|
{ "targetField": "status", "defaultValue": "지시" },
|
||||||
|
{ "targetField": "updated_date", "defaultValue": "#NOW" },
|
||||||
|
{ "targetField": "updated_by", "defaultValue": "#USER" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "action-insert-workorder",
|
||||||
|
"actionType": "insert",
|
||||||
|
"targetTable": "work_order_mng",
|
||||||
|
"fieldMappings": [
|
||||||
|
{ "sourceField": "plan_no", "targetField": "plan_no" },
|
||||||
|
{ "sourceField": "item_code", "targetField": "item_code" },
|
||||||
|
{ "sourceField": "item_name", "targetField": "item_name" },
|
||||||
|
{ "sourceField": "plan_qty", "targetField": "order_qty" },
|
||||||
|
{ "sourceField": "start_date", "targetField": "work_start_date" },
|
||||||
|
{ "sourceField": "end_date", "targetField": "work_end_date" },
|
||||||
|
{ "sourceField": "equipment_id", "targetField": "equipment_id" },
|
||||||
|
{ "sourceField": "equipment_name", "targetField": "equipment_name" },
|
||||||
|
{ "targetField": "status", "defaultValue": "대기" },
|
||||||
|
{ "targetField": "company_code", "defaultValue": "#COMPANY" },
|
||||||
|
{ "targetField": "created_date", "defaultValue": "#NOW" },
|
||||||
|
{ "targetField": "created_by", "defaultValue": "#USER" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 컴포넌트 연동 설정 (간소화 버전)
|
||||||
|
|
||||||
|
### 12.1 이벤트 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[화면 진입]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
v2-table-search-widget (검색 필터)
|
||||||
|
│ onFilterChange
|
||||||
|
▼
|
||||||
|
v2-table-list (생산계획 목록)
|
||||||
|
│ onRowSelect
|
||||||
|
▼
|
||||||
|
[버튼 활성화/비활성화]
|
||||||
|
│
|
||||||
|
├── [신규 등록] 클릭 → 등록 모달 오픈
|
||||||
|
│ │ onSave
|
||||||
|
│ ▼
|
||||||
|
│ INSERT → 테이블 Refresh
|
||||||
|
│
|
||||||
|
├── [수정] 클릭 → 수정 모달 오픈
|
||||||
|
│ │ onSave
|
||||||
|
│ ▼
|
||||||
|
│ UPDATE → 테이블 Refresh
|
||||||
|
│
|
||||||
|
├── [삭제] 클릭 → 조건 체크
|
||||||
|
│ │ 통과 시
|
||||||
|
│ ▼
|
||||||
|
│ DELETE → 테이블 Refresh
|
||||||
|
│
|
||||||
|
└── [작업지시 생성] 클릭 → 조건 체크
|
||||||
|
│ 통과 시
|
||||||
|
▼
|
||||||
|
UPDATE + INSERT → 테이블 Refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.2 연동 설정 테이블
|
||||||
|
|
||||||
|
| 소스 컴포넌트 | 이벤트 | 대상 컴포넌트 | 동작 |
|
||||||
|
|---------------|--------|---------------|------|
|
||||||
|
| search-widget | onFilterChange | main-table | 필터 적용 후 재조회 |
|
||||||
|
| main-table | onRowSelect | btn-edit, btn-delete | 버튼 활성화 |
|
||||||
|
| main-table | onRowDoubleClick | modal-edit | 수정 모달 오픈 |
|
||||||
|
| btn-register | onClick | modal-register | 등록 모달 오픈 |
|
||||||
|
| btn-edit | onClick | modal-edit | 수정 모달 오픈 |
|
||||||
|
| btn-delete | onClick | dataflow | 삭제 실행 |
|
||||||
|
| btn-work-order | onClick | dataflow | 작업지시 생성 실행 |
|
||||||
|
| modal-* | onSave | main-table | 테이블 새로고침 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 사용자 사용 예시 시나리오 (간소화 버전)
|
||||||
|
|
||||||
|
### 시나리오 1: 생산계획 조회
|
||||||
|
|
||||||
|
| 단계 | 사용자 동작 | 기대 결과 |
|
||||||
|
|:----:|-------------|-----------|
|
||||||
|
| 1 | 화면 진입 | 전체 생산계획 목록 표시 (최근순) |
|
||||||
|
| 2 | 품목코드 입력 후 [조회] 클릭 | 해당 품목의 생산계획만 필터링 |
|
||||||
|
| 3 | 상태 드롭다운에서 '계획' 선택 | 계획 상태인 항목만 표시 |
|
||||||
|
| 4 | 컬럼 헤더 클릭 | 해당 컬럼 기준 정렬 |
|
||||||
|
|
||||||
|
### 시나리오 2: 생산계획 등록
|
||||||
|
|
||||||
|
| 단계 | 사용자 동작 | 기대 결과 |
|
||||||
|
|:----:|-------------|-----------|
|
||||||
|
| 1 | [신규 등록] 버튼 클릭 | 등록 모달 오픈 |
|
||||||
|
| 2 | 품목코드 검색/선택 | 품명 자동 입력 |
|
||||||
|
| 3 | 계획수량, 시작일, 종료일 입력 | 입력 필드 채움 |
|
||||||
|
| 4 | 설비 선택 | 시간당 생산능력 표시 |
|
||||||
|
| 5 | [저장] 버튼 클릭 | 저장 완료, 목록에 신규 행 표시 |
|
||||||
|
|
||||||
|
### 시나리오 3: 생산계획 수정
|
||||||
|
|
||||||
|
| 단계 | 사용자 동작 | 기대 결과 |
|
||||||
|
|:----:|-------------|-----------|
|
||||||
|
| 1 | 목록에서 행 선택 | 행 하이라이트 |
|
||||||
|
| 2 | [수정] 버튼 클릭 (또는 더블클릭) | 수정 모달 오픈 (기존 값 로드) |
|
||||||
|
| 3 | 필요한 필드 수정 | 값 변경 |
|
||||||
|
| 4 | [저장] 버튼 클릭 | 저장 완료, 목록 갱신 |
|
||||||
|
|
||||||
|
### 시나리오 4: 생산계획 삭제
|
||||||
|
|
||||||
|
| 단계 | 사용자 동작 | 기대 결과 |
|
||||||
|
|:----:|-------------|-----------|
|
||||||
|
| 1 | 삭제할 행 체크박스 선택 | 선택 표시 |
|
||||||
|
| 2 | [삭제] 버튼 클릭 | 삭제 확인 다이얼로그 표시 |
|
||||||
|
| 3-a | (상태가 계획/지시) 확인 클릭 | 삭제 완료, 목록에서 제거 |
|
||||||
|
| 3-b | (상태가 진행/완료) 확인 클릭 | "삭제할 수 없습니다" 오류 메시지 |
|
||||||
|
|
||||||
|
### 시나리오 5: 작업지시 생성
|
||||||
|
|
||||||
|
| 단계 | 사용자 동작 | 기대 결과 |
|
||||||
|
|:----:|-------------|-----------|
|
||||||
|
| 1 | 계획 상태인 행 체크박스 선택 (다중 가능) | 선택 표시 |
|
||||||
|
| 2 | [작업지시 생성] 버튼 클릭 | 확인 다이얼로그 표시 |
|
||||||
|
| 3-a | (상태가 계획) 확인 클릭 | 상태가 '지시'로 변경, 작업지시번호 생성 |
|
||||||
|
| 3-b | (상태가 계획 아님) 확인 클릭 | "계획 상태만 가능합니다" 오류 메시지 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. 검증 체크리스트
|
||||||
|
|
||||||
|
### 14.1 기능 테스트
|
||||||
|
|
||||||
|
- [ ] 데이터 조회가 정상 동작하는가?
|
||||||
|
- [ ] 검색 필터가 정상 동작하는가?
|
||||||
|
- [ ] 신규 등록이 정상 동작하는가?
|
||||||
|
- [ ] 수정이 정상 동작하는가?
|
||||||
|
- [ ] 삭제가 정상 동작하는가? (조건 포함)
|
||||||
|
- [ ] 작업지시 생성이 정상 동작하는가? (조건 포함)
|
||||||
|
- [ ] 페이지네이션이 정상 동작하는가?
|
||||||
|
- [ ] 정렬이 정상 동작하는가?
|
||||||
|
|
||||||
|
### 14.2 데이터 검증
|
||||||
|
|
||||||
|
- [ ] 계획번호(plan_no) 자동채번이 중복 없이 생성되는가?
|
||||||
|
- [ ] 품목코드 선택 시 품명이 자동 입력되는가?
|
||||||
|
- [ ] 설비 선택 시 시간당 생산능력이 표시되는가?
|
||||||
|
- [ ] company_code 필터링이 적용되는가? (멀티테넌시)
|
||||||
|
|
||||||
|
### 14.3 비즈니스 룰 검증
|
||||||
|
|
||||||
|
- [ ] 진행/완료 상태는 삭제 불가한가?
|
||||||
|
- [ ] 계획 상태만 작업지시 생성 가능한가?
|
||||||
|
- [ ] 작업지시 생성 시 work_order_mng에 데이터가 INSERT되는가?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 구현 체크리스트
|
||||||
|
|
||||||
|
### 15.1 간소화 버전 (현재 구현 가능)
|
||||||
|
|
||||||
| 체크 | 항목 | 설명 |
|
| 체크 | 항목 | 설명 |
|
||||||
|:----:|------|------|
|
|:----:|------|------|
|
||||||
|
|
@ -576,26 +1190,37 @@ V2 컴포넌트로 기본 CRUD 화면 구현:
|
||||||
| ☐ | **화면 정의** | `screen_definitions` INSERT |
|
| ☐ | **화면 정의** | `screen_definitions` INSERT |
|
||||||
| ☐ | **모달 화면 생성** | 등록/수정 모달 화면 생성 |
|
| ☐ | **모달 화면 생성** | 등록/수정 모달 화면 생성 |
|
||||||
| ☐ | **메인 화면 생성** | `screen_layouts_v2` INSERT |
|
| ☐ | **메인 화면 생성** | `screen_layouts_v2` INSERT |
|
||||||
|
| ☐ | **제어관리 설정** | `dataflow_diagrams` INSERT (삭제, 작업지시 생성) |
|
||||||
| ☐ | **메뉴 연결** | 대상 메뉴에 화면 할당 |
|
| ☐ | **메뉴 연결** | 대상 메뉴에 화면 할당 |
|
||||||
| ☐ | **기본 CRUD 테스트** | 등록/수정/삭제/조회 테스트 |
|
| ☐ | **기본 CRUD 테스트** | 등록/수정/삭제/조회 테스트 |
|
||||||
|
| ☐ | **비즈니스 룰 테스트** | 조건부 삭제, 작업지시 생성 테스트 |
|
||||||
|
|
||||||
### 10.2 전체 버전 (향후 구현)
|
### 15.2 전체 버전 (향후 구현)
|
||||||
|
|
||||||
| 체크 | 항목 | 설명 |
|
| 체크 | 항목 | 설명 |
|
||||||
|:----:|------|------|
|
|:----:|------|------|
|
||||||
| ☐ | **v2-tabs 개발** | 탭 컴포넌트 개발 |
|
| ✅ | ~~**v2-split-panel-layout 확장**~~ | `displayMode: "custom"` + `components[]` 추가 **완료** |
|
||||||
| ☐ | **v2-split-panel 개발** | 분할 패널 컴포넌트 개발 |
|
| ✅ | ~~**v2-table-grouped 개발**~~ | 그룹화 테이블 컴포넌트 **완료** |
|
||||||
| ☐ | **v2-table-grouped 개발** | 그룹화 테이블 컴포넌트 개발 |
|
| ☐ | **v2-timeline-scheduler 개발** | 타임라인/간트차트 스케줄러 |
|
||||||
| ☐ | **v2-timeline-scheduler 개발** | 타임라인 스케줄러 개발 |
|
| ☐ | **드래그&드롭 기능** | 타임라인 바 드래그 이동/크기 조정 |
|
||||||
| ☐ | **자동 스케줄 로직** | 자동 스케줄 생성 백엔드 API |
|
| ☐ | **자동 스케줄 로직** | 자동 스케줄 생성 백엔드 API |
|
||||||
| ☐ | **반제품 연동** | BOM 기반 반제품 계획 생성 |
|
| ☐ | **반제품 연동** | BOM 기반 반제품 계획 생성 |
|
||||||
| ☐ | **드래그&드롭** | 타임라인 드래그 이동 기능 |
|
| ☐ | **정식 버전 화면 구성** | 커스텀 모드로 탭+테이블+타임라인 조합 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. 관련 문서
|
## 16. 관련 문서
|
||||||
|
|
||||||
- [수주관리](../02_sales/order.md)
|
- [수주관리](../02_sales/order.md)
|
||||||
- [품목정보](../01_master-data/item-info.md)
|
- [품목정보](../01_master-data/item-info.md)
|
||||||
- [설비관리](../05_equipment/equipment-info.md) (예정)
|
- [설비관리](../05_equipment/equipment-info.md)
|
||||||
- [BOM관리](../01_master-data/bom-info.md) (예정)
|
- [BOM관리](../01_master-data/bom.md)
|
||||||
|
- [작업지시](./work-order.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. 참고: 표준 가이드
|
||||||
|
|
||||||
|
- [화면개발 표준 가이드](../화면개발_표준_가이드.md)
|
||||||
|
- [V2 컴포넌트 사용 가이드](../00_analysis/v2-component-usage-guide.md)
|
||||||
|
- [전체 화면 분석 보고서](../00_analysis/full-screen-analysis.md)
|
||||||
|
|
|
||||||
|
|
@ -202,7 +202,89 @@ function ScreenViewPage() {
|
||||||
}
|
}
|
||||||
}, [screenId]);
|
}, [screenId]);
|
||||||
|
|
||||||
// 🆕 autoFill 자동 입력 초기화
|
// 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
||||||
|
// 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움
|
||||||
|
useEffect(() => {
|
||||||
|
const loadMainTableData = async () => {
|
||||||
|
if (!screen || !layout || !layout.components || !companyCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainTableName = screen.tableName;
|
||||||
|
if (!mainTableName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블 위젯이 없는 경우에만 자동 로드 (테이블이 있으면 행 선택으로 데이터 로드)
|
||||||
|
const hasTableWidget = layout.components.some(
|
||||||
|
(comp: any) =>
|
||||||
|
comp.componentType === "table-list" ||
|
||||||
|
comp.componentType === "v2-table-list" ||
|
||||||
|
comp.widgetType === "table"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasTableWidget) {
|
||||||
|
console.log("📋 테이블 위젯이 있어 자동 로드 건너뜀 (행 선택으로 데이터 로드)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인풋 컴포넌트들 중 메인 테이블의 컬럼을 사용하는 것들 찾기
|
||||||
|
const inputComponents = layout.components.filter((comp: any) => {
|
||||||
|
const compType = comp.componentType || comp.widgetType;
|
||||||
|
const isInputType = compType?.includes("input") ||
|
||||||
|
compType?.includes("select") ||
|
||||||
|
compType?.includes("textarea") ||
|
||||||
|
compType?.includes("v2-input") ||
|
||||||
|
compType?.includes("v2-select");
|
||||||
|
const hasColumnName = !!(comp as any).columnName;
|
||||||
|
return isInputType && hasColumnName;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (inputComponents.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 메인 테이블에서 현재 회사의 데이터 조회
|
||||||
|
try {
|
||||||
|
const { tableTypeApi } = await import("@/lib/api/screen");
|
||||||
|
|
||||||
|
// company_code로 필터링하여 단일 레코드 조회
|
||||||
|
const result = await tableTypeApi.getTableRecord(
|
||||||
|
mainTableName,
|
||||||
|
"company_code",
|
||||||
|
companyCode,
|
||||||
|
"*" // 모든 컬럼
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result && result.record) {
|
||||||
|
console.log("📦 메인 테이블 데이터 자동 로드:", mainTableName, result.record);
|
||||||
|
|
||||||
|
// 각 인풋 컴포넌트에 해당하는 데이터 채우기
|
||||||
|
const newFormData: Record<string, any> = {};
|
||||||
|
inputComponents.forEach((comp: any) => {
|
||||||
|
const columnName = comp.columnName;
|
||||||
|
if (columnName && result.record[columnName] !== undefined) {
|
||||||
|
newFormData[columnName] = result.record[columnName];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (Object.keys(newFormData).length > 0) {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...newFormData,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("메인 테이블 자동 로드 실패 (정상일 수 있음):", error);
|
||||||
|
// 에러는 무시 - 데이터가 없거나 권한이 없을 수 있음
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadMainTableData();
|
||||||
|
}, [screen, layout, companyCode]);
|
||||||
|
|
||||||
|
// 🆕 개별 autoFill 처리 (메인 테이블과 다른 테이블에서 조회하는 경우)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initAutoFill = async () => {
|
const initAutoFill = async () => {
|
||||||
if (!layout || !layout.components || !user) {
|
if (!layout || !layout.components || !user) {
|
||||||
|
|
@ -215,7 +297,7 @@ function ScreenViewPage() {
|
||||||
const widget = comp as any;
|
const widget = comp as any;
|
||||||
const fieldName = widget.columnName || widget.id;
|
const fieldName = widget.columnName || widget.id;
|
||||||
|
|
||||||
// autoFill 처리
|
// autoFill 처리 (명시적으로 설정된 경우만)
|
||||||
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
|
if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) {
|
||||||
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
|
const autoFillConfig = widget.autoFill || (comp as any).autoFill;
|
||||||
const currentValue = formData[fieldName];
|
const currentValue = formData[fieldName];
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ interface RealtimePreviewProps {
|
||||||
onUpdateComponent?: (updatedComponent: any) => void; // 🆕 컴포넌트 업데이트 콜백
|
onUpdateComponent?: (updatedComponent: any) => void; // 🆕 컴포넌트 업데이트 콜백
|
||||||
onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백
|
onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백
|
||||||
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
|
selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID
|
||||||
|
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||||
|
selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID
|
||||||
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
|
onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백
|
||||||
|
|
||||||
// 버튼 액션을 위한 props
|
// 버튼 액션을 위한 props
|
||||||
|
|
@ -140,6 +142,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백
|
onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백
|
||||||
onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백
|
onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백
|
||||||
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
|
selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID
|
||||||
|
onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||||
|
selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID
|
||||||
onResize, // 🆕 리사이즈 콜백
|
onResize, // 🆕 리사이즈 콜백
|
||||||
}) => {
|
}) => {
|
||||||
// 🆕 화면 다국어 컨텍스트
|
// 🆕 화면 다국어 컨텍스트
|
||||||
|
|
@ -640,6 +644,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||||
onUpdateComponent={onUpdateComponent}
|
onUpdateComponent={onUpdateComponent}
|
||||||
onSelectTabComponent={onSelectTabComponent}
|
onSelectTabComponent={onSelectTabComponent}
|
||||||
selectedTabComponentId={selectedTabComponentId}
|
selectedTabComponentId={selectedTabComponentId}
|
||||||
|
onSelectPanelComponent={onSelectPanelComponent}
|
||||||
|
selectedPanelComponentId={selectedPanelComponentId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -177,13 +177,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
component: any; // 탭 내부 컴포넌트 데이터
|
component: any; // 탭 내부 컴포넌트 데이터
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// 🆕 분할 패널 내부 컴포넌트 선택 상태
|
||||||
|
const [selectedPanelComponentInfo, setSelectedPanelComponentInfo] = useState<{
|
||||||
|
splitPanelId: string; // 분할 패널 컴포넌트 ID
|
||||||
|
panelSide: "left" | "right"; // 좌측/우측 패널
|
||||||
|
componentId: string; // 패널 내부 컴포넌트 ID
|
||||||
|
component: any; // 패널 내부 컴포넌트 데이터
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// 컴포넌트 선택 시 통합 패널 자동 열기
|
// 컴포넌트 선택 시 통합 패널 자동 열기
|
||||||
const handleComponentSelect = useCallback(
|
const handleComponentSelect = useCallback(
|
||||||
(component: ComponentData | null) => {
|
(component: ComponentData | null) => {
|
||||||
setSelectedComponent(component);
|
setSelectedComponent(component);
|
||||||
// 일반 컴포넌트 선택 시 탭 내부 컴포넌트 선택 해제
|
// 일반 컴포넌트 선택 시 탭 내부 컴포넌트/분할 패널 내부 컴포넌트 선택 해제
|
||||||
if (component) {
|
if (component) {
|
||||||
setSelectedTabComponentInfo(null);
|
setSelectedTabComponentInfo(null);
|
||||||
|
setSelectedPanelComponentInfo(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컴포넌트가 선택되면 통합 패널 자동 열기
|
// 컴포넌트가 선택되면 통합 패널 자동 열기
|
||||||
|
|
@ -209,8 +218,32 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
componentId: compId,
|
componentId: compId,
|
||||||
component: comp,
|
component: comp,
|
||||||
});
|
});
|
||||||
// 탭 내부 컴포넌트 선택 시 일반 컴포넌트 선택 해제
|
// 탭 내부 컴포넌트 선택 시 일반 컴포넌트/분할 패널 내부 컴포넌트 선택 해제
|
||||||
setSelectedComponent(null);
|
setSelectedComponent(null);
|
||||||
|
setSelectedPanelComponentInfo(null);
|
||||||
|
openPanel("v2");
|
||||||
|
},
|
||||||
|
[openPanel],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🆕 분할 패널 내부 컴포넌트 선택 핸들러
|
||||||
|
const handleSelectPanelComponent = useCallback(
|
||||||
|
(splitPanelId: string, panelSide: "left" | "right", compId: string, comp: any) => {
|
||||||
|
if (!compId) {
|
||||||
|
// 패널 영역 빈 공간 클릭 시 선택 해제
|
||||||
|
setSelectedPanelComponentInfo(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedPanelComponentInfo({
|
||||||
|
splitPanelId,
|
||||||
|
panelSide,
|
||||||
|
componentId: compId,
|
||||||
|
component: comp,
|
||||||
|
});
|
||||||
|
// 분할 패널 내부 컴포넌트 선택 시 일반 컴포넌트/탭 내부 컴포넌트 선택 해제
|
||||||
|
setSelectedComponent(null);
|
||||||
|
setSelectedTabComponentInfo(null);
|
||||||
openPanel("v2");
|
openPanel("v2");
|
||||||
},
|
},
|
||||||
[openPanel],
|
[openPanel],
|
||||||
|
|
@ -2509,6 +2542,72 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🎯 분할 패널 커스텀 모드 컨테이너 내부 드롭 처리
|
||||||
|
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
||||||
|
if (splitPanelContainer) {
|
||||||
|
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||||
|
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
|
||||||
|
if (containerId && panelSide) {
|
||||||
|
const targetComponent = layout.components.find((c) => c.id === containerId);
|
||||||
|
const compType = (targetComponent as any)?.componentType;
|
||||||
|
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
|
||||||
|
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||||
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
|
const currentComponents = panelConfig.components || [];
|
||||||
|
|
||||||
|
// 드롭 위치 계산
|
||||||
|
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||||
|
const dropX = (e.clientX - panelRect.left) / zoomLevel;
|
||||||
|
const dropY = (e.clientY - panelRect.top) / zoomLevel;
|
||||||
|
|
||||||
|
// 새 컴포넌트 생성
|
||||||
|
const componentType = component.id || component.componentType || "v2-text-display";
|
||||||
|
|
||||||
|
console.log("🎯 분할 패널에 컴포넌트 드롭:", {
|
||||||
|
componentId: component.id,
|
||||||
|
componentType: componentType,
|
||||||
|
panelSide: panelSide,
|
||||||
|
dropPosition: { x: dropX, y: dropY },
|
||||||
|
});
|
||||||
|
|
||||||
|
const newPanelComponent = {
|
||||||
|
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
componentType: componentType,
|
||||||
|
label: component.name || component.label || "새 컴포넌트",
|
||||||
|
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
|
||||||
|
size: component.defaultSize || { width: 200, height: 100 },
|
||||||
|
componentConfig: component.defaultConfig || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedPanelConfig = {
|
||||||
|
...panelConfig,
|
||||||
|
components: [...currentComponents, newPanelComponent],
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedComponent = {
|
||||||
|
...targetComponent,
|
||||||
|
componentConfig: {
|
||||||
|
...currentConfig,
|
||||||
|
[panelKey]: updatedPanelConfig,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c) =>
|
||||||
|
c.id === containerId ? updatedComponent : c
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
setLayout(newLayout);
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
toast.success(`컴포넌트가 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
|
||||||
|
return; // 분할 패널 처리 완료
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
||||||
|
|
@ -2996,6 +3095,105 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🎯 분할 패널 커스텀 모드 컨테이너 내부에 컬럼 드롭 시 처리
|
||||||
|
const splitPanelContainer = dropTarget.closest('[data-split-panel-container="true"]');
|
||||||
|
if (splitPanelContainer && type === "column" && column) {
|
||||||
|
const containerId = splitPanelContainer.getAttribute("data-component-id");
|
||||||
|
const panelSide = splitPanelContainer.getAttribute("data-panel-side"); // "left" or "right"
|
||||||
|
if (containerId && panelSide) {
|
||||||
|
const targetComponent = layout.components.find((c) => c.id === containerId);
|
||||||
|
const compType = (targetComponent as any)?.componentType;
|
||||||
|
if (targetComponent && (compType === "split-panel-layout" || compType === "v2-split-panel-layout")) {
|
||||||
|
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||||
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
|
const currentComponents = panelConfig.components || [];
|
||||||
|
|
||||||
|
// 드롭 위치 계산
|
||||||
|
const panelRect = splitPanelContainer.getBoundingClientRect();
|
||||||
|
const dropX = (e.clientX - panelRect.left) / zoomLevel;
|
||||||
|
const dropY = (e.clientY - panelRect.top) / zoomLevel;
|
||||||
|
|
||||||
|
// V2 컴포넌트 매핑 사용
|
||||||
|
const v2Mapping = createV2ConfigFromColumn({
|
||||||
|
widgetType: column.widgetType,
|
||||||
|
columnName: column.columnName,
|
||||||
|
columnLabel: column.columnLabel,
|
||||||
|
codeCategory: column.codeCategory,
|
||||||
|
inputType: column.inputType,
|
||||||
|
required: column.required,
|
||||||
|
detailSettings: column.detailSettings,
|
||||||
|
referenceTable: column.referenceTable,
|
||||||
|
referenceColumn: column.referenceColumn,
|
||||||
|
displayColumn: column.displayColumn,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 웹타입별 기본 크기 계산
|
||||||
|
const getPanelComponentSize = (widgetType: string) => {
|
||||||
|
const sizeMap: Record<string, { width: number; height: number }> = {
|
||||||
|
text: { width: 200, height: 36 },
|
||||||
|
number: { width: 150, height: 36 },
|
||||||
|
decimal: { width: 150, height: 36 },
|
||||||
|
date: { width: 180, height: 36 },
|
||||||
|
datetime: { width: 200, height: 36 },
|
||||||
|
select: { width: 200, height: 36 },
|
||||||
|
category: { width: 200, height: 36 },
|
||||||
|
code: { width: 200, height: 36 },
|
||||||
|
entity: { width: 220, height: 36 },
|
||||||
|
boolean: { width: 120, height: 36 },
|
||||||
|
checkbox: { width: 120, height: 36 },
|
||||||
|
textarea: { width: 300, height: 100 },
|
||||||
|
file: { width: 250, height: 80 },
|
||||||
|
};
|
||||||
|
return sizeMap[widgetType] || { width: 200, height: 36 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const componentSize = getPanelComponentSize(column.widgetType);
|
||||||
|
|
||||||
|
const newPanelComponent = {
|
||||||
|
id: `panel_comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
componentType: v2Mapping.componentType,
|
||||||
|
label: column.columnLabel || column.columnName,
|
||||||
|
position: { x: Math.max(0, dropX), y: Math.max(0, dropY) },
|
||||||
|
size: componentSize,
|
||||||
|
inputType: column.inputType || column.widgetType,
|
||||||
|
widgetType: column.widgetType,
|
||||||
|
componentConfig: {
|
||||||
|
...v2Mapping.componentConfig,
|
||||||
|
columnName: column.columnName,
|
||||||
|
tableName: column.tableName,
|
||||||
|
inputType: column.inputType || column.widgetType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedPanelConfig = {
|
||||||
|
...panelConfig,
|
||||||
|
components: [...currentComponents, newPanelComponent],
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedComponent = {
|
||||||
|
...targetComponent,
|
||||||
|
componentConfig: {
|
||||||
|
...currentConfig,
|
||||||
|
[panelKey]: updatedPanelConfig,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: layout.components.map((c) =>
|
||||||
|
c.id === containerId ? updatedComponent : c
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
setLayout(newLayout);
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
toast.success(`컬럼이 ${panelSide === "left" ? "좌측" : "우측"} 패널에 추가되었습니다`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rect = canvasRef.current?.getBoundingClientRect();
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
||||||
|
|
@ -5123,6 +5321,158 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
|
) : selectedPanelComponentInfo ? (
|
||||||
|
// 🆕 분할 패널 내부 컴포넌트 선택 시 V2PropertiesPanel 사용
|
||||||
|
(() => {
|
||||||
|
const panelComp = selectedPanelComponentInfo.component;
|
||||||
|
|
||||||
|
// 분할 패널 내부 컴포넌트를 ComponentData 형식으로 변환
|
||||||
|
const panelComponentAsComponentData: ComponentData = {
|
||||||
|
id: panelComp.id,
|
||||||
|
type: "component",
|
||||||
|
componentType: panelComp.componentType,
|
||||||
|
label: panelComp.label,
|
||||||
|
position: panelComp.position || { x: 0, y: 0 },
|
||||||
|
size: panelComp.size || { width: 200, height: 100 },
|
||||||
|
componentConfig: panelComp.componentConfig || {},
|
||||||
|
style: panelComp.style || {},
|
||||||
|
} as ComponentData;
|
||||||
|
|
||||||
|
// 분할 패널 내부 컴포넌트용 속성 업데이트 핸들러
|
||||||
|
const updatePanelComponentProperty = (componentId: string, path: string, value: any) => {
|
||||||
|
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
|
||||||
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
|
||||||
|
setLayout((prevLayout) => {
|
||||||
|
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
|
||||||
|
if (!splitPanelComponent) return prevLayout;
|
||||||
|
|
||||||
|
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
||||||
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
|
const components = panelConfig.components || [];
|
||||||
|
|
||||||
|
// 해당 컴포넌트 찾기
|
||||||
|
const targetCompIndex = components.findIndex((c: any) => c.id === componentId);
|
||||||
|
if (targetCompIndex === -1) return prevLayout;
|
||||||
|
|
||||||
|
// 컴포넌트 속성 업데이트
|
||||||
|
const targetComp = components[targetCompIndex];
|
||||||
|
const updatedComp = path === "style"
|
||||||
|
? { ...targetComp, style: value }
|
||||||
|
: path.includes(".")
|
||||||
|
? (() => {
|
||||||
|
const parts = path.split(".");
|
||||||
|
let obj = { ...targetComp };
|
||||||
|
let current: any = obj;
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
current[parts[i]] = { ...current[parts[i]] };
|
||||||
|
current = current[parts[i]];
|
||||||
|
}
|
||||||
|
current[parts[parts.length - 1]] = value;
|
||||||
|
return obj;
|
||||||
|
})()
|
||||||
|
: { ...targetComp, [path]: value };
|
||||||
|
|
||||||
|
const updatedComponents = [
|
||||||
|
...components.slice(0, targetCompIndex),
|
||||||
|
updatedComp,
|
||||||
|
...components.slice(targetCompIndex + 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
const updatedComponent = {
|
||||||
|
...splitPanelComponent,
|
||||||
|
componentConfig: {
|
||||||
|
...currentConfig,
|
||||||
|
[panelKey]: {
|
||||||
|
...panelConfig,
|
||||||
|
components: updatedComponents,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// selectedPanelComponentInfo 업데이트
|
||||||
|
setSelectedPanelComponentInfo(prev =>
|
||||||
|
prev ? { ...prev, component: updatedComp } : null
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prevLayout,
|
||||||
|
components: prevLayout.components.map((c) =>
|
||||||
|
c.id === splitPanelId ? updatedComponent : c
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 분할 패널 내부 컴포넌트 삭제 핸들러
|
||||||
|
const deletePanelComponent = (componentId: string) => {
|
||||||
|
const { splitPanelId, panelSide } = selectedPanelComponentInfo;
|
||||||
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
|
||||||
|
setLayout((prevLayout) => {
|
||||||
|
const splitPanelComponent = prevLayout.components.find((c) => c.id === splitPanelId);
|
||||||
|
if (!splitPanelComponent) return prevLayout;
|
||||||
|
|
||||||
|
const currentConfig = (splitPanelComponent as any).componentConfig || {};
|
||||||
|
const panelConfig = currentConfig[panelKey] || {};
|
||||||
|
const components = panelConfig.components || [];
|
||||||
|
|
||||||
|
const updatedComponents = components.filter((c: any) => c.id !== componentId);
|
||||||
|
|
||||||
|
const updatedComponent = {
|
||||||
|
...splitPanelComponent,
|
||||||
|
componentConfig: {
|
||||||
|
...currentConfig,
|
||||||
|
[panelKey]: {
|
||||||
|
...panelConfig,
|
||||||
|
components: updatedComponents,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setSelectedPanelComponentInfo(null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prevLayout,
|
||||||
|
components: prevLayout.components.map((c) =>
|
||||||
|
c.id === splitPanelId ? updatedComponent : c
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
분할 패널 ({selectedPanelComponentInfo.panelSide === "left" ? "좌측" : "우측"}) 컴포넌트
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => setSelectedPanelComponentInfo(null)}
|
||||||
|
>
|
||||||
|
선택 해제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<V2PropertiesPanel
|
||||||
|
selectedComponent={panelComponentAsComponentData}
|
||||||
|
tables={tables}
|
||||||
|
onUpdateProperty={updatePanelComponentProperty}
|
||||||
|
onDeleteComponent={deletePanelComponent}
|
||||||
|
currentTable={tables.length > 0 ? tables[0] : undefined}
|
||||||
|
currentTableName={selectedScreen?.tableName}
|
||||||
|
currentScreenCompanyCode={selectedScreen?.companyCode}
|
||||||
|
onStyleChange={(style) => {
|
||||||
|
updatePanelComponentProperty(panelComp.id, "style", style);
|
||||||
|
}}
|
||||||
|
allComponents={layout.components}
|
||||||
|
menuObjid={menuObjid}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
) : (
|
) : (
|
||||||
<V2PropertiesPanel
|
<V2PropertiesPanel
|
||||||
selectedComponent={selectedComponent || undefined}
|
selectedComponent={selectedComponent || undefined}
|
||||||
|
|
@ -5514,6 +5864,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
||||||
? selectedTabComponentInfo.componentId
|
? selectedTabComponentInfo.componentId
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
// 🆕 분할 패널 내부 컴포넌트 선택 핸들러
|
||||||
|
onSelectPanelComponent={(panelSide, compId, comp) =>
|
||||||
|
handleSelectPanelComponent(component.id, panelSide, compId, comp)
|
||||||
|
}
|
||||||
|
selectedPanelComponentId={
|
||||||
|
selectedPanelComponentInfo?.splitPanelId === component.id
|
||||||
|
? selectedPanelComponentInfo.componentId
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
|
{/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
|
||||||
{(component.type === "group" ||
|
{(component.type === "group" ||
|
||||||
|
|
|
||||||
|
|
@ -431,7 +431,8 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={joinTable.tableName} className="space-y-1">
|
// 엔티티 조인 테이블에 고유 접두사 추가 (메인 테이블과 키 중복 방지)
|
||||||
|
<div key={`entity-join-${joinTable.tableName}`} className="space-y-1">
|
||||||
{/* 조인 테이블 헤더 */}
|
{/* 조인 테이블 헤더 */}
|
||||||
<div
|
<div
|
||||||
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"
|
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,9 @@ export interface DynamicComponentRendererProps {
|
||||||
// 🆕 탭 내부 컴포넌트 선택 콜백
|
// 🆕 탭 내부 컴포넌트 선택 콜백
|
||||||
onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void;
|
onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void;
|
||||||
selectedTabComponentId?: string;
|
selectedTabComponentId?: string;
|
||||||
|
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||||
|
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void;
|
||||||
|
selectedPanelComponentId?: string;
|
||||||
flowSelectedStepId?: number | null;
|
flowSelectedStepId?: number | null;
|
||||||
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
|
onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void;
|
||||||
// 테이블 새로고침 키
|
// 테이블 새로고침 키
|
||||||
|
|
@ -502,6 +505,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// 🆕 탭 내부 컴포넌트 선택 콜백
|
// 🆕 탭 내부 컴포넌트 선택 콜백
|
||||||
onSelectTabComponent: props.onSelectTabComponent,
|
onSelectTabComponent: props.onSelectTabComponent,
|
||||||
selectedTabComponentId: props.selectedTabComponentId,
|
selectedTabComponentId: props.selectedTabComponentId,
|
||||||
|
// 🆕 분할 패널 내부 컴포넌트 선택 콜백
|
||||||
|
onSelectPanelComponent: props.onSelectPanelComponent,
|
||||||
|
selectedPanelComponentId: props.selectedPanelComponentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 렌더러가 클래스인지 함수인지 확인
|
// 렌더러가 클래스인지 함수인지 확인
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@ import "./v2-table-search-widget";
|
||||||
import "./v2-tabs-widget/tabs-component";
|
import "./v2-tabs-widget/tabs-component";
|
||||||
import "./v2-category-manager/V2CategoryManagerRenderer";
|
import "./v2-category-manager/V2CategoryManagerRenderer";
|
||||||
import "./v2-media/V2MediaRenderer"; // V2 통합 미디어 컴포넌트
|
import "./v2-media/V2MediaRenderer"; // V2 통합 미디어 컴포넌트
|
||||||
|
import "./v2-table-grouped/TableGroupedRenderer"; // 그룹화 테이블
|
||||||
|
import "./v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스케줄러
|
||||||
import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
|
import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트
|
||||||
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트
|
||||||
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Settings,
|
||||||
|
Move,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
|
|
@ -37,9 +39,16 @@ import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
|
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import { useSplitPanel } from "./SplitPanelContext";
|
import { useSplitPanel } from "./SplitPanelContext";
|
||||||
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||||
|
import { PanelInlineComponent } from "./types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
|
||||||
// 추가 props
|
// 추가 props
|
||||||
|
onUpdateComponent?: (component: any) => void;
|
||||||
|
// 🆕 패널 내부 컴포넌트 선택 콜백 (탭 컴포넌트와 동일 구조)
|
||||||
|
onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: PanelInlineComponent) => void;
|
||||||
|
selectedPanelComponentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -52,6 +61,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
isPreview = false,
|
isPreview = false,
|
||||||
onClick,
|
onClick,
|
||||||
|
onUpdateComponent,
|
||||||
|
onSelectPanelComponent,
|
||||||
|
selectedPanelComponentId: externalSelectedPanelComponentId,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig;
|
const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig;
|
||||||
|
|
@ -181,6 +193,207 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
const [rightCategoryMappings, setRightCategoryMappings] = useState<
|
const [rightCategoryMappings, setRightCategoryMappings] = useState<
|
||||||
Record<string, Record<string, { label: string; color?: string }>>
|
Record<string, Record<string, { label: string; color?: string }>>
|
||||||
>({}); // 우측 카테고리 매핑
|
>({}); // 우측 카테고리 매핑
|
||||||
|
|
||||||
|
// 🆕 커스텀 모드: 드래그/리사이즈 상태
|
||||||
|
const [draggingCompId, setDraggingCompId] = useState<string | null>(null);
|
||||||
|
const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [resizingCompId, setResizingCompId] = useState<string | null>(null);
|
||||||
|
const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null);
|
||||||
|
// 🆕 외부에서 전달받은 선택 상태 사용 (탭 컴포넌트와 동일 구조)
|
||||||
|
const selectedPanelComponentId = externalSelectedPanelComponentId || null;
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// 🆕 10px 단위 스냅 함수
|
||||||
|
const snapTo10 = useCallback((value: number) => Math.round(value / 10) * 10, []);
|
||||||
|
|
||||||
|
// 🆕 커스텀 모드: 컴포넌트 삭제 핸들러
|
||||||
|
const handleRemovePanelComponent = useCallback(
|
||||||
|
(panelSide: "left" | "right", compId: string) => {
|
||||||
|
if (!onUpdateComponent) return;
|
||||||
|
|
||||||
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
const panelConfig = componentConfig[panelKey] || {};
|
||||||
|
const updatedComponents = (panelConfig.components || []).filter(
|
||||||
|
(c: PanelInlineComponent) => c.id !== compId
|
||||||
|
);
|
||||||
|
|
||||||
|
onUpdateComponent({
|
||||||
|
...component,
|
||||||
|
componentConfig: {
|
||||||
|
...componentConfig,
|
||||||
|
[panelKey]: {
|
||||||
|
...panelConfig,
|
||||||
|
components: updatedComponents,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[component, componentConfig, onUpdateComponent]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🆕 커스텀 모드: 드래그 시작 핸들러
|
||||||
|
const handlePanelDragStart = useCallback(
|
||||||
|
(e: React.MouseEvent, panelSide: "left" | "right", comp: PanelInlineComponent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const startMouseX = e.clientX;
|
||||||
|
const startMouseY = e.clientY;
|
||||||
|
const startLeft = comp.position?.x || 0;
|
||||||
|
const startTop = comp.position?.y || 0;
|
||||||
|
|
||||||
|
setDraggingCompId(comp.id);
|
||||||
|
setDragPosition({ x: startLeft, y: startTop });
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
if (rafRef.current) {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
|
const deltaX = moveEvent.clientX - startMouseX;
|
||||||
|
const deltaY = moveEvent.clientY - startMouseY;
|
||||||
|
const newX = Math.max(0, startLeft + deltaX);
|
||||||
|
const newY = Math.max(0, startTop + deltaY);
|
||||||
|
setDragPosition({ x: newX, y: newY });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = (upEvent: MouseEvent) => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
|
if (rafRef.current) {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = upEvent.clientX - startMouseX;
|
||||||
|
const deltaY = upEvent.clientY - startMouseY;
|
||||||
|
const newX = Math.max(0, startLeft + deltaX);
|
||||||
|
const newY = Math.max(0, startTop + deltaY);
|
||||||
|
|
||||||
|
setDraggingCompId(null);
|
||||||
|
setDragPosition(null);
|
||||||
|
|
||||||
|
if (onUpdateComponent) {
|
||||||
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
const panelConfig = componentConfig[panelKey] || {};
|
||||||
|
const updatedComponents = (panelConfig.components || []).map((c: PanelInlineComponent) =>
|
||||||
|
c.id === comp.id
|
||||||
|
? { ...c, position: { x: Math.round(newX), y: Math.round(newY) } }
|
||||||
|
: c
|
||||||
|
);
|
||||||
|
|
||||||
|
onUpdateComponent({
|
||||||
|
...component,
|
||||||
|
componentConfig: {
|
||||||
|
...componentConfig,
|
||||||
|
[panelKey]: {
|
||||||
|
...panelConfig,
|
||||||
|
components: updatedComponents,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
},
|
||||||
|
[component, componentConfig, onUpdateComponent]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🆕 커스텀 모드: 리사이즈 시작 핸들러
|
||||||
|
const handlePanelResizeStart = useCallback(
|
||||||
|
(e: React.MouseEvent, panelSide: "left" | "right", comp: PanelInlineComponent, direction: "e" | "s" | "se") => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const startMouseX = e.clientX;
|
||||||
|
const startMouseY = e.clientY;
|
||||||
|
const startWidth = comp.size?.width || 200;
|
||||||
|
const startHeight = comp.size?.height || 100;
|
||||||
|
|
||||||
|
setResizingCompId(comp.id);
|
||||||
|
setResizeSize({ width: startWidth, height: startHeight });
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
if (rafRef.current) {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
|
const deltaX = moveEvent.clientX - startMouseX;
|
||||||
|
const deltaY = moveEvent.clientY - startMouseY;
|
||||||
|
|
||||||
|
let newWidth = startWidth;
|
||||||
|
let newHeight = startHeight;
|
||||||
|
|
||||||
|
if (direction === "e" || direction === "se") {
|
||||||
|
newWidth = snapTo10(Math.max(50, startWidth + deltaX));
|
||||||
|
}
|
||||||
|
if (direction === "s" || direction === "se") {
|
||||||
|
newHeight = snapTo10(Math.max(30, startHeight + deltaY));
|
||||||
|
}
|
||||||
|
|
||||||
|
setResizeSize({ width: newWidth, height: newHeight });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = (upEvent: MouseEvent) => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
|
if (rafRef.current) {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaX = upEvent.clientX - startMouseX;
|
||||||
|
const deltaY = upEvent.clientY - startMouseY;
|
||||||
|
|
||||||
|
let newWidth = startWidth;
|
||||||
|
let newHeight = startHeight;
|
||||||
|
|
||||||
|
if (direction === "e" || direction === "se") {
|
||||||
|
newWidth = snapTo10(Math.max(50, startWidth + deltaX));
|
||||||
|
}
|
||||||
|
if (direction === "s" || direction === "se") {
|
||||||
|
newHeight = snapTo10(Math.max(30, startHeight + deltaY));
|
||||||
|
}
|
||||||
|
|
||||||
|
setResizingCompId(null);
|
||||||
|
setResizeSize(null);
|
||||||
|
|
||||||
|
if (onUpdateComponent) {
|
||||||
|
const panelKey = panelSide === "left" ? "leftPanel" : "rightPanel";
|
||||||
|
const panelConfig = componentConfig[panelKey] || {};
|
||||||
|
const updatedComponents = (panelConfig.components || []).map((c: PanelInlineComponent) =>
|
||||||
|
c.id === comp.id
|
||||||
|
? { ...c, size: { width: newWidth, height: newHeight } }
|
||||||
|
: c
|
||||||
|
);
|
||||||
|
|
||||||
|
onUpdateComponent({
|
||||||
|
...component,
|
||||||
|
componentConfig: {
|
||||||
|
...componentConfig,
|
||||||
|
[panelKey]: {
|
||||||
|
...panelConfig,
|
||||||
|
components: updatedComponents,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
},
|
||||||
|
[component, componentConfig, onUpdateComponent, snapTo10]
|
||||||
|
);
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
// 추가 모달 상태
|
// 추가 모달 상태
|
||||||
|
|
@ -2079,8 +2292,191 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardContent className="flex-1 overflow-auto p-4">
|
<CardContent className="flex-1 overflow-auto p-4">
|
||||||
{/* 좌측 데이터 목록/테이블 */}
|
{/* 좌측 데이터 목록/테이블/커스텀 */}
|
||||||
{componentConfig.leftPanel?.displayMode === "table" ? (
|
{componentConfig.leftPanel?.displayMode === "custom" ? (
|
||||||
|
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
||||||
|
<div
|
||||||
|
className="relative h-full w-full"
|
||||||
|
data-split-panel-container="true"
|
||||||
|
data-component-id={component.id}
|
||||||
|
data-panel-side="left"
|
||||||
|
>
|
||||||
|
{/* 🆕 커스텀 모드: 디자인/실행 모드 통합 렌더링 */}
|
||||||
|
{componentConfig.leftPanel?.components && componentConfig.leftPanel.components.length > 0 ? (
|
||||||
|
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
|
||||||
|
{componentConfig.leftPanel.components.map((comp: PanelInlineComponent) => {
|
||||||
|
const isSelectedComp = selectedPanelComponentId === comp.id;
|
||||||
|
const isDraggingComp = draggingCompId === comp.id;
|
||||||
|
const isResizingComp = resizingCompId === comp.id;
|
||||||
|
|
||||||
|
// 드래그/리사이즈 중 표시할 크기/위치
|
||||||
|
const displayX = isDraggingComp && dragPosition ? dragPosition.x : (comp.position?.x || 0);
|
||||||
|
const displayY = isDraggingComp && dragPosition ? dragPosition.y : (comp.position?.y || 0);
|
||||||
|
const displayWidth = isResizingComp && resizeSize ? resizeSize.width : (comp.size?.width || 200);
|
||||||
|
const displayHeight = isResizingComp && resizeSize ? resizeSize.height : (comp.size?.height || 100);
|
||||||
|
|
||||||
|
// 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환
|
||||||
|
const componentData = {
|
||||||
|
id: comp.id,
|
||||||
|
type: "component" as const,
|
||||||
|
componentType: comp.componentType,
|
||||||
|
label: comp.label,
|
||||||
|
position: comp.position || { x: 0, y: 0 },
|
||||||
|
size: { width: displayWidth, height: displayHeight },
|
||||||
|
componentConfig: comp.componentConfig || {},
|
||||||
|
style: comp.style || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDesignMode) {
|
||||||
|
// 디자인 모드: 탭 컴포넌트와 동일하게 실제 컴포넌트 렌더링
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
data-panel-comp-id={comp.id}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: displayX,
|
||||||
|
top: displayY,
|
||||||
|
zIndex: isDraggingComp ? 100 : isSelectedComp ? 10 : 1,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectPanelComponent?.("left", comp.id, comp);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 드래그 핸들 - 컴포넌트 외부 상단 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-4 cursor-move items-center justify-between rounded-t border border-b-0 bg-gray-100 px-1",
|
||||||
|
isSelectedComp ? "border-primary" : "border-gray-200"
|
||||||
|
)}
|
||||||
|
style={{ width: displayWidth }}
|
||||||
|
onMouseDown={(e) => handlePanelDragStart(e, "left", comp)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<Move className="h-2.5 w-2.5 text-gray-400" />
|
||||||
|
<span className="max-w-[100px] truncate text-[9px] text-gray-500">
|
||||||
|
{comp.label || comp.componentType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
className="rounded p-0.5 hover:bg-gray-200"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectPanelComponent?.("left", comp.id, comp);
|
||||||
|
}}
|
||||||
|
title="설정"
|
||||||
|
>
|
||||||
|
<Settings className="h-2.5 w-2.5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded p-0.5 hover:bg-red-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemovePanelComponent("left", comp.id);
|
||||||
|
}}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-2.5 w-2.5 text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative overflow-hidden rounded-b border bg-white shadow-sm",
|
||||||
|
isSelectedComp
|
||||||
|
? "border-primary ring-2 ring-primary/30"
|
||||||
|
: "border-gray-200",
|
||||||
|
(isDraggingComp || isResizingComp) && "opacity-80 shadow-lg",
|
||||||
|
!(isDraggingComp || isResizingComp) && "transition-all"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: displayWidth,
|
||||||
|
height: displayHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none h-full w-full">
|
||||||
|
<DynamicComponentRenderer
|
||||||
|
component={componentData as any}
|
||||||
|
isDesignMode={true}
|
||||||
|
formData={{}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리사이즈 가장자리 영역 - 선택된 컴포넌트에만 표시 */}
|
||||||
|
{isSelectedComp && (
|
||||||
|
<>
|
||||||
|
{/* 오른쪽 가장자리 (너비 조절) */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto absolute right-0 top-0 z-10 h-full w-2 cursor-ew-resize hover:bg-primary/10"
|
||||||
|
onMouseDown={(e) => handlePanelResizeStart(e, "left", comp, "e")}
|
||||||
|
/>
|
||||||
|
{/* 아래 가장자리 (높이 조절) */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto absolute bottom-0 left-0 z-10 h-2 w-full cursor-ns-resize hover:bg-primary/10"
|
||||||
|
onMouseDown={(e) => handlePanelResizeStart(e, "left", comp, "s")}
|
||||||
|
/>
|
||||||
|
{/* 오른쪽 아래 모서리 (너비+높이 조절) */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto absolute bottom-0 right-0 z-20 h-3 w-3 cursor-nwse-resize hover:bg-primary/20"
|
||||||
|
onMouseDown={(e) => handlePanelResizeStart(e, "left", comp, "se")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 실행 모드: DynamicComponentRenderer로 렌더링
|
||||||
|
const componentData = {
|
||||||
|
id: comp.id,
|
||||||
|
type: "component" as const,
|
||||||
|
componentType: comp.componentType,
|
||||||
|
label: comp.label,
|
||||||
|
position: comp.position || { x: 0, y: 0 },
|
||||||
|
size: comp.size || { width: 400, height: 300 },
|
||||||
|
componentConfig: comp.componentConfig || {},
|
||||||
|
style: comp.style || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: comp.position?.x || 0,
|
||||||
|
top: comp.position?.y || 0,
|
||||||
|
width: comp.size?.width || 400,
|
||||||
|
height: comp.size?.height || 300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DynamicComponentRenderer
|
||||||
|
component={componentData as any}
|
||||||
|
isDesignMode={false}
|
||||||
|
formData={{}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 컴포넌트가 없을 때 드롭 영역 표시
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50/50">
|
||||||
|
<Plus className="mb-2 h-8 w-8 text-gray-400" />
|
||||||
|
<p className="text-sm font-medium text-gray-500">
|
||||||
|
커스텀 모드
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-400">
|
||||||
|
{isDesignMode ? "컴포넌트를 드래그하여 배치하세요" : "배치된 컴포넌트가 없습니다"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : componentConfig.leftPanel?.displayMode === "table" ? (
|
||||||
// 테이블 모드
|
// 테이블 모드
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{isDesignMode ? (
|
{isDesignMode ? (
|
||||||
|
|
@ -2577,8 +2973,180 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardContent className="flex-1 overflow-auto p-4">
|
<CardContent className="flex-1 overflow-auto p-4">
|
||||||
{/* 우측 데이터 */}
|
{/* 우측 데이터/커스텀 */}
|
||||||
{isLoadingRight ? (
|
{componentConfig.rightPanel?.displayMode === "custom" ? (
|
||||||
|
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
||||||
|
<div
|
||||||
|
className="relative h-full w-full"
|
||||||
|
data-split-panel-container="true"
|
||||||
|
data-component-id={component.id}
|
||||||
|
data-panel-side="right"
|
||||||
|
>
|
||||||
|
{/* 🆕 커스텀 모드: 디자인/실행 모드 통합 렌더링 */}
|
||||||
|
{componentConfig.rightPanel?.components && componentConfig.rightPanel.components.length > 0 ? (
|
||||||
|
<div className="relative h-full w-full" style={{ minHeight: "100%", minWidth: "100%" }}>
|
||||||
|
{componentConfig.rightPanel.components.map((comp: PanelInlineComponent) => {
|
||||||
|
const isSelectedComp = selectedPanelComponentId === comp.id;
|
||||||
|
const isDraggingComp = draggingCompId === comp.id;
|
||||||
|
const isResizingComp = resizingCompId === comp.id;
|
||||||
|
|
||||||
|
// 드래그/리사이즈 중 표시할 크기/위치
|
||||||
|
const displayX = isDraggingComp && dragPosition ? dragPosition.x : (comp.position?.x || 0);
|
||||||
|
const displayY = isDraggingComp && dragPosition ? dragPosition.y : (comp.position?.y || 0);
|
||||||
|
const displayWidth = isResizingComp && resizeSize ? resizeSize.width : (comp.size?.width || 200);
|
||||||
|
const displayHeight = isResizingComp && resizeSize ? resizeSize.height : (comp.size?.height || 100);
|
||||||
|
|
||||||
|
// 컴포넌트 데이터를 DynamicComponentRenderer 형식으로 변환
|
||||||
|
const componentData = {
|
||||||
|
id: comp.id,
|
||||||
|
type: "component" as const,
|
||||||
|
componentType: comp.componentType,
|
||||||
|
label: comp.label,
|
||||||
|
position: comp.position || { x: 0, y: 0 },
|
||||||
|
size: { width: displayWidth, height: displayHeight },
|
||||||
|
componentConfig: comp.componentConfig || {},
|
||||||
|
style: comp.style || {},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDesignMode) {
|
||||||
|
// 디자인 모드: 탭 컴포넌트와 동일하게 실제 컴포넌트 렌더링
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
data-panel-comp-id={comp.id}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: displayX,
|
||||||
|
top: displayY,
|
||||||
|
zIndex: isDraggingComp ? 100 : isSelectedComp ? 10 : 1,
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectPanelComponent?.("right", comp.id, comp);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 드래그 핸들 - 컴포넌트 외부 상단 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-4 cursor-move items-center justify-between rounded-t border border-b-0 bg-gray-100 px-1",
|
||||||
|
isSelectedComp ? "border-primary" : "border-gray-200"
|
||||||
|
)}
|
||||||
|
style={{ width: displayWidth }}
|
||||||
|
onMouseDown={(e) => handlePanelDragStart(e, "right", comp)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<Move className="h-2.5 w-2.5 text-gray-400" />
|
||||||
|
<span className="max-w-[100px] truncate text-[9px] text-gray-500">
|
||||||
|
{comp.label || comp.componentType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
className="rounded p-0.5 hover:bg-gray-200"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectPanelComponent?.("right", comp.id, comp);
|
||||||
|
}}
|
||||||
|
title="설정"
|
||||||
|
>
|
||||||
|
<Settings className="h-2.5 w-2.5 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded p-0.5 hover:bg-red-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemovePanelComponent("right", comp.id);
|
||||||
|
}}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-2.5 w-2.5 text-red-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative overflow-hidden rounded-b border bg-white shadow-sm",
|
||||||
|
isSelectedComp
|
||||||
|
? "border-primary ring-2 ring-primary/30"
|
||||||
|
: "border-gray-200",
|
||||||
|
(isDraggingComp || isResizingComp) && "opacity-80 shadow-lg",
|
||||||
|
!(isDraggingComp || isResizingComp) && "transition-all"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: displayWidth,
|
||||||
|
height: displayHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none h-full w-full">
|
||||||
|
<DynamicComponentRenderer
|
||||||
|
component={componentData as any}
|
||||||
|
isDesignMode={true}
|
||||||
|
formData={{}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리사이즈 가장자리 영역 - 선택된 컴포넌트에만 표시 */}
|
||||||
|
{isSelectedComp && (
|
||||||
|
<>
|
||||||
|
{/* 오른쪽 가장자리 (너비 조절) */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto absolute right-0 top-0 z-10 h-full w-2 cursor-ew-resize hover:bg-primary/10"
|
||||||
|
onMouseDown={(e) => handlePanelResizeStart(e, "right", comp, "e")}
|
||||||
|
/>
|
||||||
|
{/* 아래 가장자리 (높이 조절) */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto absolute bottom-0 left-0 z-10 h-2 w-full cursor-ns-resize hover:bg-primary/10"
|
||||||
|
onMouseDown={(e) => handlePanelResizeStart(e, "right", comp, "s")}
|
||||||
|
/>
|
||||||
|
{/* 오른쪽 아래 모서리 (너비+높이 조절) */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-auto absolute bottom-0 right-0 z-20 h-3 w-3 cursor-nwse-resize hover:bg-primary/20"
|
||||||
|
onMouseDown={(e) => handlePanelResizeStart(e, "right", comp, "se")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: comp.position?.x || 0,
|
||||||
|
top: comp.position?.y || 0,
|
||||||
|
width: comp.size?.width || 400,
|
||||||
|
height: comp.size?.height || 300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DynamicComponentRenderer
|
||||||
|
component={componentData as any}
|
||||||
|
isDesignMode={false}
|
||||||
|
formData={{}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 컴포넌트가 없을 때 드롭 영역 표시
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50/50">
|
||||||
|
<Plus className="mb-2 h-8 w-8 text-gray-400" />
|
||||||
|
<p className="text-sm font-medium text-gray-500">
|
||||||
|
커스텀 모드
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-400">
|
||||||
|
{isDesignMode ? "컴포넌트를 드래그하여 배치하세요" : "배치된 컴포넌트가 없습니다"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : isLoadingRight ? (
|
||||||
// 로딩 중
|
// 로딩 중
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
// Accordion 제거 - 단순 섹션으로 변경
|
// Accordion 제거 - 단순 섹션으로 변경
|
||||||
import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown, Trash2, Database, GripVertical } from "lucide-react";
|
import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown, Trash2, Database, GripVertical, Move } from "lucide-react";
|
||||||
|
import { PanelInlineComponent } from "./types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SplitPanelLayoutConfig, AdditionalTabConfig } from "./types";
|
import { SplitPanelLayoutConfig, AdditionalTabConfig } from "./types";
|
||||||
import { TableInfo, ColumnInfo } from "@/types/screen";
|
import { TableInfo, ColumnInfo } from "@/types/screen";
|
||||||
|
|
@ -1547,7 +1548,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
<Label>표시 모드</Label>
|
<Label>표시 모드</Label>
|
||||||
<Select
|
<Select
|
||||||
value={config.leftPanel?.displayMode || "list"}
|
value={config.leftPanel?.displayMode || "list"}
|
||||||
onValueChange={(value: "list" | "table") => updateLeftPanel({ displayMode: value })}
|
onValueChange={(value: "list" | "table" | "custom") => updateLeftPanel({ displayMode: value })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-white">
|
<SelectTrigger className="bg-white">
|
||||||
<SelectValue placeholder="표시 모드 선택" />
|
<SelectValue placeholder="표시 모드 선택" />
|
||||||
|
|
@ -1565,11 +1566,75 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
<span className="text-xs text-gray-500">컬럼 헤더가 있는 테이블 형식</span>
|
<span className="text-xs text-gray-500">컬럼 헤더가 있는 테이블 형식</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="custom">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">커스텀 (CUSTOM)</span>
|
||||||
|
<span className="text-xs text-gray-500">패널 안에 컴포넌트 자유 배치</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{config.leftPanel?.displayMode === "custom" && (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
화면 디자이너에서 좌측 패널에 컴포넌트를 드래그하여 배치하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 좌측 패널 표시 컬럼 설정 - 체크박스 방식 */}
|
{/* 🆕 커스텀 모드: 배치된 컴포넌트 목록 */}
|
||||||
|
{config.leftPanel?.displayMode === "custom" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">배치된 컴포넌트</Label>
|
||||||
|
{!config.leftPanel?.components || config.leftPanel.components.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
|
||||||
|
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
디자인 화면에서 컴포넌트를 드래그하여 추가하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{config.leftPanel.components.map((comp: PanelInlineComponent) => (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className="flex items-center justify-between rounded-md border bg-background p-2"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs font-medium">
|
||||||
|
{comp.label || comp.componentType}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
{comp.componentType} | 위치: ({comp.position?.x || 0}, {comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x{comp.size?.height || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const updatedComponents = (config.leftPanel?.components || []).filter(
|
||||||
|
(c: PanelInlineComponent) => c.id !== comp.id
|
||||||
|
);
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
leftPanel: {
|
||||||
|
...config.leftPanel,
|
||||||
|
components: updatedComponents,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 좌측 패널 표시 컬럼 설정 - 체크박스 방식 (커스텀 모드가 아닐 때만) */}
|
||||||
|
{config.leftPanel?.displayMode !== "custom" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium">표시할 컬럼 선택</Label>
|
<Label className="text-xs font-medium">표시할 컬럼 선택</Label>
|
||||||
|
|
||||||
|
|
@ -1731,6 +1796,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 좌측 패널 데이터 필터링 */}
|
{/* 좌측 패널 데이터 필터링 */}
|
||||||
|
|
@ -1851,7 +1917,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
<Label>표시 모드</Label>
|
<Label>표시 모드</Label>
|
||||||
<Select
|
<Select
|
||||||
value={config.rightPanel?.displayMode || "list"}
|
value={config.rightPanel?.displayMode || "list"}
|
||||||
onValueChange={(value: "list" | "table") => updateRightPanel({ displayMode: value })}
|
onValueChange={(value: "list" | "table" | "custom") => updateRightPanel({ displayMode: value })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="bg-white">
|
<SelectTrigger className="bg-white">
|
||||||
<SelectValue placeholder="표시 모드 선택" />
|
<SelectValue placeholder="표시 모드 선택" />
|
||||||
|
|
@ -1869,11 +1935,74 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
<span className="text-xs text-gray-500">컬럼 헤더가 있는 테이블 형식</span>
|
<span className="text-xs text-gray-500">컬럼 헤더가 있는 테이블 형식</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="custom">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">커스텀 (CUSTOM)</span>
|
||||||
|
<span className="text-xs text-gray-500">패널 안에 컴포넌트 자유 배치</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{config.rightPanel?.displayMode === "custom" && (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
화면 디자이너에서 우측 패널에 컴포넌트를 드래그하여 배치하세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 요약 표시 설정 (LIST 모드에서만) */}
|
{/* 🆕 커스텀 모드: 배치된 컴포넌트 목록 */}
|
||||||
|
{config.rightPanel?.displayMode === "custom" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">배치된 컴포넌트</Label>
|
||||||
|
{!config.rightPanel?.components || config.rightPanel.components.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed bg-muted/30 p-4 text-center">
|
||||||
|
<Move className="mx-auto mb-2 h-6 w-6 text-muted-foreground" />
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
디자인 화면에서 컴포넌트를 드래그하여 추가하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{config.rightPanel.components.map((comp: PanelInlineComponent) => (
|
||||||
|
<div
|
||||||
|
key={comp.id}
|
||||||
|
className="flex items-center justify-between rounded-md border bg-background p-2"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs font-medium">
|
||||||
|
{comp.label || comp.componentType}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
{comp.componentType} | 위치: ({comp.position?.x || 0}, {comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x{comp.size?.height || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const updatedComponents = (config.rightPanel?.components || []).filter(
|
||||||
|
(c: PanelInlineComponent) => c.id !== comp.id
|
||||||
|
);
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
rightPanel: {
|
||||||
|
...config.rightPanel,
|
||||||
|
components: updatedComponents,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 요약 표시 설정 (LIST 모드에서만, 커스텀 모드가 아닐 때) */}
|
||||||
{(config.rightPanel?.displayMode || "list") === "list" && (
|
{(config.rightPanel?.displayMode || "list") === "list" && (
|
||||||
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||||
<Label className="text-sm font-semibold">요약 표시 설정</Label>
|
<Label className="text-sm font-semibold">요약 표시 설정</Label>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,13 @@
|
||||||
* SplitPanelLayout 컴포넌트 타입 정의
|
* SplitPanelLayout 컴포넌트 타입 정의
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { DataFilterConfig } from "@/types/screen-management";
|
import { DataFilterConfig, TabInlineComponent } from "@/types/screen-management";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 패널 내 인라인 컴포넌트 (커스텀 모드용)
|
||||||
|
* TabInlineComponent와 동일한 구조 사용
|
||||||
|
*/
|
||||||
|
export type PanelInlineComponent = TabInlineComponent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 추가 탭 설정 (우측 패널과 동일한 구조 + tabId, label)
|
* 추가 탭 설정 (우측 패널과 동일한 구조 + tabId, label)
|
||||||
|
|
@ -118,7 +124,9 @@ export interface SplitPanelLayoutConfig {
|
||||||
useCustomTable?: boolean; // 화면 기본 테이블이 아닌 다른 테이블 사용 여부
|
useCustomTable?: boolean; // 화면 기본 테이블이 아닌 다른 테이블 사용 여부
|
||||||
customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때)
|
customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때)
|
||||||
dataSource?: string; // API 엔드포인트
|
dataSource?: string; // API 엔드포인트
|
||||||
displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블
|
displayMode?: "list" | "table" | "custom"; // 표시 모드: 목록, 테이블, 또는 커스텀
|
||||||
|
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 (탭 컴포넌트와 동일 구조)
|
||||||
|
components?: PanelInlineComponent[];
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
showAdd?: boolean;
|
showAdd?: boolean;
|
||||||
showEdit?: boolean; // 수정 버튼
|
showEdit?: boolean; // 수정 버튼
|
||||||
|
|
@ -185,7 +193,9 @@ export interface SplitPanelLayoutConfig {
|
||||||
useCustomTable?: boolean; // 화면 기본 테이블이 아닌 다른 테이블 사용 여부
|
useCustomTable?: boolean; // 화면 기본 테이블이 아닌 다른 테이블 사용 여부
|
||||||
customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때)
|
customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때)
|
||||||
dataSource?: string;
|
dataSource?: string;
|
||||||
displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블
|
displayMode?: "list" | "table" | "custom"; // 표시 모드: 목록, 테이블, 또는 커스텀
|
||||||
|
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 (탭 컴포넌트와 동일 구조)
|
||||||
|
components?: PanelInlineComponent[];
|
||||||
showSearch?: boolean;
|
showSearch?: boolean;
|
||||||
showAdd?: boolean;
|
showAdd?: boolean;
|
||||||
showEdit?: boolean; // 수정 버튼
|
showEdit?: boolean; // 수정 버튼
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
# v2-table-grouped (그룹화 테이블)
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공하는 테이블 컴포넌트입니다.
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
|
||||||
|
- **그룹화**: 지정된 컬럼 기준으로 데이터를 그룹핑
|
||||||
|
- **접기/펼치기**: 그룹 헤더 클릭으로 하위 항목 토글
|
||||||
|
- **그룹 요약**: 그룹별 개수, 합계, 평균, 최대/최소값 표시
|
||||||
|
- **체크박스 선택**: 그룹 단위 또는 개별 항목 선택
|
||||||
|
- **전체 펼치기/접기**: 모든 그룹 일괄 토글 버튼
|
||||||
|
|
||||||
|
## 사용 예시
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { TableGroupedComponent } from "./TableGroupedComponent";
|
||||||
|
|
||||||
|
<TableGroupedComponent
|
||||||
|
config={{
|
||||||
|
selectedTable: "production_plan_mng",
|
||||||
|
groupConfig: {
|
||||||
|
groupByColumn: "item_code",
|
||||||
|
groupLabelFormat: "{item_name} ({item_code})",
|
||||||
|
defaultExpanded: true,
|
||||||
|
summary: {
|
||||||
|
showCount: true,
|
||||||
|
sumColumns: ["quantity"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ columnName: "plan_no", displayName: "계획번호", visible: true },
|
||||||
|
{ columnName: "plan_date", displayName: "계획일", visible: true },
|
||||||
|
{ columnName: "quantity", displayName: "수량", visible: true, format: "number" },
|
||||||
|
],
|
||||||
|
showCheckbox: true,
|
||||||
|
checkboxMode: "multi",
|
||||||
|
showExpandAllButton: true,
|
||||||
|
}}
|
||||||
|
onSelectionChange={(event) => console.log("선택:", event.selectedItems)}
|
||||||
|
onRowClick={(event) => console.log("행 클릭:", event.row)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 설정 옵션
|
||||||
|
|
||||||
|
### 기본 설정
|
||||||
|
|
||||||
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `selectedTable` | string | - | 데이터 테이블명 |
|
||||||
|
| `useCustomTable` | boolean | false | 커스텀 테이블 사용 여부 |
|
||||||
|
| `customTableName` | string | - | 커스텀 테이블명 |
|
||||||
|
| `columns` | ColumnConfig[] | [] | 표시할 컬럼 설정 |
|
||||||
|
|
||||||
|
### 그룹화 설정 (groupConfig)
|
||||||
|
|
||||||
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `groupByColumn` | string | - | 그룹화 기준 컬럼 (필수) |
|
||||||
|
| `groupLabelFormat` | string | "{value}" | 그룹 라벨 형식 |
|
||||||
|
| `defaultExpanded` | boolean | true | 초기 펼침 상태 |
|
||||||
|
| `sortDirection` | "asc" \| "desc" | "asc" | 그룹 정렬 방향 |
|
||||||
|
| `summary.showCount` | boolean | true | 개수 표시 여부 |
|
||||||
|
| `summary.sumColumns` | string[] | [] | 합계 컬럼 목록 |
|
||||||
|
| `summary.avgColumns` | string[] | [] | 평균 컬럼 목록 |
|
||||||
|
|
||||||
|
### 표시 설정
|
||||||
|
|
||||||
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `showCheckbox` | boolean | false | 체크박스 표시 |
|
||||||
|
| `checkboxMode` | "single" \| "multi" | "multi" | 선택 모드 |
|
||||||
|
| `showExpandAllButton` | boolean | true | 전체 펼치기/접기 버튼 |
|
||||||
|
| `groupHeaderStyle` | "default" \| "compact" \| "card" | "default" | 그룹 헤더 스타일 |
|
||||||
|
| `rowClickable` | boolean | true | 행 클릭 가능 여부 |
|
||||||
|
| `maxHeight` | number | 600 | 최대 높이 (px) |
|
||||||
|
| `emptyMessage` | string | "데이터가 없습니다." | 빈 데이터 메시지 |
|
||||||
|
|
||||||
|
## 이벤트
|
||||||
|
|
||||||
|
### onSelectionChange
|
||||||
|
|
||||||
|
선택 상태가 변경될 때 호출됩니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SelectionChangeEvent {
|
||||||
|
selectedGroups: string[]; // 선택된 그룹 키 목록
|
||||||
|
selectedItems: any[]; // 선택된 아이템 전체
|
||||||
|
isAllSelected: boolean; // 전체 선택 여부
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### onGroupToggle
|
||||||
|
|
||||||
|
그룹 펼치기/접기 시 호출됩니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GroupToggleEvent {
|
||||||
|
groupKey: string; // 그룹 키
|
||||||
|
expanded: boolean; // 펼침 상태
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### onRowClick
|
||||||
|
|
||||||
|
행 클릭 시 호출됩니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RowClickEvent {
|
||||||
|
row: any; // 클릭된 행 데이터
|
||||||
|
groupKey: string; // 그룹 키
|
||||||
|
indexInGroup: number; // 그룹 내 인덱스
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 그룹 라벨 형식
|
||||||
|
|
||||||
|
`groupLabelFormat`에서 사용 가능한 플레이스홀더:
|
||||||
|
|
||||||
|
- `{value}`: 그룹화 컬럼의 값
|
||||||
|
- `{컬럼명}`: 해당 컬럼의 값
|
||||||
|
|
||||||
|
**예시:**
|
||||||
|
```
|
||||||
|
groupLabelFormat: "{item_name} ({item_code}) - {category}"
|
||||||
|
// 결과: "제품A (P001) - 완제품"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
v2-table-grouped/
|
||||||
|
├── index.ts # Definition
|
||||||
|
├── types.ts # 타입 정의
|
||||||
|
├── config.ts # 기본 설정값
|
||||||
|
├── TableGroupedComponent.tsx # 메인 컴포넌트
|
||||||
|
├── TableGroupedConfigPanel.tsx # 설정 패널
|
||||||
|
├── TableGroupedRenderer.tsx # 레지스트리 등록
|
||||||
|
├── components/
|
||||||
|
│ └── GroupHeader.tsx # 그룹 헤더
|
||||||
|
├── hooks/
|
||||||
|
│ └── useGroupedData.ts # 그룹화 로직 훅
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## v2-table-list와의 차이점
|
||||||
|
|
||||||
|
| 항목 | v2-table-list | v2-table-grouped |
|
||||||
|
|------|---------------|------------------|
|
||||||
|
| 데이터 구조 | 플랫 리스트 | 계층 구조 (그룹 > 아이템) |
|
||||||
|
| 렌더링 | 행 단위 | 그룹 헤더 + 상세 행 |
|
||||||
|
| 선택 | 개별 행 | 그룹 단위 / 개별 단위 |
|
||||||
|
| 요약 | 전체 합계 (선택) | 그룹별 요약 |
|
||||||
|
| 용도 | 일반 데이터 목록 | 카테고리별 분류 데이터 |
|
||||||
|
|
||||||
|
## 관련 컴포넌트
|
||||||
|
|
||||||
|
- `v2-table-list`: 기본 테이블 (그룹화 없음)
|
||||||
|
- `v2-pivot-grid`: 피벗 테이블 (다차원 집계)
|
||||||
|
- `v2-split-panel-layout`: 마스터-디테일 레이아웃
|
||||||
|
|
@ -0,0 +1,529 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useEffect, useState, useMemo } from "react";
|
||||||
|
import { Loader2, FoldVertical, UnfoldVertical } from "lucide-react";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { TableGroupedComponentProps } from "./types";
|
||||||
|
import { useGroupedData } from "./hooks/useGroupedData";
|
||||||
|
import { GroupHeader } from "./components/GroupHeader";
|
||||||
|
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||||
|
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||||
|
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2-table-grouped 메인 컴포넌트
|
||||||
|
*
|
||||||
|
* 데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공합니다.
|
||||||
|
*/
|
||||||
|
export function TableGroupedComponent({
|
||||||
|
config,
|
||||||
|
isDesignMode = false,
|
||||||
|
formData,
|
||||||
|
onSelectionChange,
|
||||||
|
onGroupToggle,
|
||||||
|
onRowClick,
|
||||||
|
externalData,
|
||||||
|
isLoading: externalLoading,
|
||||||
|
error: externalError,
|
||||||
|
componentId,
|
||||||
|
}: TableGroupedComponentProps) {
|
||||||
|
// 화면 컨텍스트 (데이터 제공자로 등록)
|
||||||
|
const screenContext = useScreenContextOptional();
|
||||||
|
|
||||||
|
// TableOptions Context (검색필터 연동)
|
||||||
|
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
|
||||||
|
|
||||||
|
// 연결된 필터 상태 (다른 컴포넌트 값으로 필터링)
|
||||||
|
const [linkedFilterValues, setLinkedFilterValues] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// 필터 및 그룹 설정 상태 (검색필터 연동용)
|
||||||
|
const [filters, setFilters] = useState<any[]>([]);
|
||||||
|
const [grouping, setGrouping] = useState<string[]>([]);
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// 그룹화 데이터 훅 (검색 필터 전달)
|
||||||
|
const {
|
||||||
|
groups,
|
||||||
|
isLoading: hookLoading,
|
||||||
|
error: hookError,
|
||||||
|
toggleGroup,
|
||||||
|
expandAll,
|
||||||
|
collapseAll,
|
||||||
|
toggleItemSelection,
|
||||||
|
toggleGroupSelection,
|
||||||
|
toggleAllSelection,
|
||||||
|
selectedItems,
|
||||||
|
isAllSelected,
|
||||||
|
isIndeterminate,
|
||||||
|
refresh,
|
||||||
|
rawData,
|
||||||
|
totalCount,
|
||||||
|
groupCount,
|
||||||
|
} = useGroupedData(config, externalData, linkedFilterValues);
|
||||||
|
|
||||||
|
const isLoading = externalLoading ?? hookLoading;
|
||||||
|
const error = externalError ?? hookError;
|
||||||
|
|
||||||
|
// 필터링된 데이터 (훅에서 이미 필터 적용됨)
|
||||||
|
const filteredData = rawData;
|
||||||
|
|
||||||
|
// 연결된 필터 감시
|
||||||
|
useEffect(() => {
|
||||||
|
const linkedFilters = config.linkedFilters;
|
||||||
|
|
||||||
|
if (!linkedFilters || linkedFilters.length === 0 || !screenContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연결된 소스 컴포넌트들의 값을 주기적으로 확인
|
||||||
|
const checkLinkedFilters = () => {
|
||||||
|
const newFilterValues: Record<string, any> = {};
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
linkedFilters.forEach((filter) => {
|
||||||
|
if (filter.enabled === false) return;
|
||||||
|
|
||||||
|
const sourceProvider = screenContext.getDataProvider(filter.sourceComponentId);
|
||||||
|
if (sourceProvider) {
|
||||||
|
const selectedData = sourceProvider.getSelectedData();
|
||||||
|
if (selectedData && selectedData.length > 0) {
|
||||||
|
const sourceField = filter.sourceField || "value";
|
||||||
|
const value = selectedData[0][sourceField];
|
||||||
|
|
||||||
|
if (value !== linkedFilterValues[filter.targetColumn]) {
|
||||||
|
newFilterValues[filter.targetColumn] = value;
|
||||||
|
hasChanges = true;
|
||||||
|
} else {
|
||||||
|
newFilterValues[filter.targetColumn] = linkedFilterValues[filter.targetColumn];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
setLinkedFilterValues(newFilterValues);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 확인
|
||||||
|
checkLinkedFilters();
|
||||||
|
|
||||||
|
// 주기적 확인 (100ms 간격)
|
||||||
|
const intervalId = setInterval(checkLinkedFilters, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [screenContext, config.linkedFilters, linkedFilterValues]);
|
||||||
|
|
||||||
|
// DataProvidable 인터페이스 구현
|
||||||
|
const dataProvider: DataProvidable = useMemo(
|
||||||
|
() => ({
|
||||||
|
componentId: componentId || "",
|
||||||
|
componentType: "table-grouped",
|
||||||
|
|
||||||
|
getSelectedData: () => {
|
||||||
|
return selectedItems;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAllData: () => {
|
||||||
|
return filteredData;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSelection: () => {
|
||||||
|
toggleAllSelection();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[componentId, selectedItems, filteredData, toggleAllSelection]
|
||||||
|
);
|
||||||
|
|
||||||
|
// DataReceivable 인터페이스 구현
|
||||||
|
const dataReceiver: DataReceivable = useMemo(
|
||||||
|
() => ({
|
||||||
|
componentId: componentId || "",
|
||||||
|
componentType: "table-grouped",
|
||||||
|
|
||||||
|
receiveData: async (_receivedData: any[], _config: DataReceiverConfig) => {
|
||||||
|
// 현재는 외부 데이터 수신 시 새로고침만 수행
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
|
||||||
|
clearData: async () => {
|
||||||
|
// 데이터 클리어 시 새로고침
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
|
||||||
|
getConfig: () => {
|
||||||
|
return {
|
||||||
|
targetComponentId: componentId || "",
|
||||||
|
mode: "replace" as const,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[componentId, refresh]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 화면 컨텍스트에 데이터 제공자/수신자로 등록
|
||||||
|
useEffect(() => {
|
||||||
|
if (screenContext && componentId) {
|
||||||
|
screenContext.registerDataProvider(componentId, dataProvider);
|
||||||
|
screenContext.registerDataReceiver(componentId, dataReceiver);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
screenContext.unregisterDataProvider(componentId);
|
||||||
|
screenContext.unregisterDataReceiver(componentId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [screenContext, componentId, dataProvider, dataReceiver]);
|
||||||
|
|
||||||
|
// 테이블 ID (검색필터 연동용)
|
||||||
|
const tableId = componentId || `table-grouped-${config.selectedTable || "default"}`;
|
||||||
|
|
||||||
|
// TableOptionsContext에 테이블 등록 (검색필터가 테이블을 찾을 수 있도록)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDesignMode || !config.selectedTable) return;
|
||||||
|
|
||||||
|
const columnsToRegister = config.columns || [];
|
||||||
|
|
||||||
|
// 고유 값 조회 함수
|
||||||
|
const getColumnUniqueValues = async (columnName: string) => {
|
||||||
|
const uniqueValues = new Set<string>();
|
||||||
|
rawData.forEach((row) => {
|
||||||
|
const value = row[columnName];
|
||||||
|
if (value !== null && value !== undefined && value !== "") {
|
||||||
|
uniqueValues.add(String(value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(uniqueValues)
|
||||||
|
.map((value) => ({ value, label: value }))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
};
|
||||||
|
|
||||||
|
const registration = {
|
||||||
|
tableId,
|
||||||
|
label: config.selectedTable,
|
||||||
|
tableName: config.selectedTable,
|
||||||
|
dataCount: totalCount,
|
||||||
|
columns: columnsToRegister.map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.displayName || col.columnName,
|
||||||
|
inputType: "text",
|
||||||
|
visible: col.visible !== false,
|
||||||
|
width: col.width || 150,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
})),
|
||||||
|
onFilterChange: setFilters,
|
||||||
|
onGroupChange: setGrouping,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
getColumnUniqueValues,
|
||||||
|
};
|
||||||
|
|
||||||
|
registerTable(registration);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unregisterTable(tableId);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tableId, config.selectedTable, config.columns, totalCount, rawData, registerTable, isDesignMode]);
|
||||||
|
|
||||||
|
// 데이터 건수 변경 시 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDesignMode && config.selectedTable) {
|
||||||
|
updateTableDataCount(tableId, totalCount);
|
||||||
|
}
|
||||||
|
}, [tableId, totalCount, updateTableDataCount, config.selectedTable, isDesignMode]);
|
||||||
|
|
||||||
|
// 필터 변경 시 검색 조건 적용
|
||||||
|
useEffect(() => {
|
||||||
|
if (filters.length > 0) {
|
||||||
|
const newFilterValues: Record<string, any> = {};
|
||||||
|
filters.forEach((filter: any) => {
|
||||||
|
if (filter.value) {
|
||||||
|
newFilterValues[filter.columnName] = filter.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setLinkedFilterValues((prev) => ({ ...prev, ...newFilterValues }));
|
||||||
|
}
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
// 컬럼 설정
|
||||||
|
const columns = config.columns || [];
|
||||||
|
const visibleColumns = columns.filter((col) => col.visible !== false);
|
||||||
|
|
||||||
|
// 체크박스 컬럼 포함 시 총 컬럼 수
|
||||||
|
const totalColumnCount = visibleColumns.length + (config.showCheckbox ? 1 : 0);
|
||||||
|
|
||||||
|
// 아이템 ID 추출 함수
|
||||||
|
const getItemId = useCallback(
|
||||||
|
(item: any): string => {
|
||||||
|
if (item.id !== undefined) return String(item.id);
|
||||||
|
const firstCol = columns[0]?.columnName;
|
||||||
|
if (firstCol && item[firstCol] !== undefined) return String(item[firstCol]);
|
||||||
|
return JSON.stringify(item);
|
||||||
|
},
|
||||||
|
[columns]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 선택 변경 시 콜백
|
||||||
|
useEffect(() => {
|
||||||
|
if (onSelectionChange && selectedItems.length >= 0) {
|
||||||
|
onSelectionChange({
|
||||||
|
selectedGroups: groups
|
||||||
|
.filter((g) => g.selected)
|
||||||
|
.map((g) => g.groupKey),
|
||||||
|
selectedItems,
|
||||||
|
isAllSelected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedItems, groups, isAllSelected, onSelectionChange]);
|
||||||
|
|
||||||
|
// 그룹 토글 핸들러
|
||||||
|
const handleGroupToggle = useCallback(
|
||||||
|
(groupKey: string) => {
|
||||||
|
toggleGroup(groupKey);
|
||||||
|
if (onGroupToggle) {
|
||||||
|
const group = groups.find((g) => g.groupKey === groupKey);
|
||||||
|
onGroupToggle({
|
||||||
|
groupKey,
|
||||||
|
expanded: !group?.expanded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[toggleGroup, onGroupToggle, groups]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 행 클릭 핸들러
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(row: any, groupKey: string, indexInGroup: number) => {
|
||||||
|
if (!config.rowClickable) return;
|
||||||
|
if (onRowClick) {
|
||||||
|
onRowClick({ row, groupKey, indexInGroup });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config.rowClickable, onRowClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// refreshTable 이벤트 구독
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRefresh = () => {
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("refreshTable", handleRefresh);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("refreshTable", handleRefresh);
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
// 디자인 모드 렌더링
|
||||||
|
if (isDesignMode) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-muted/20 p-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<FoldVertical className="h-4 w-4" />
|
||||||
|
<span>그룹화 테이블</span>
|
||||||
|
{config.groupConfig?.groupByColumn && (
|
||||||
|
<span className="text-xs">
|
||||||
|
(그룹: {config.groupConfig.groupByColumn})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
테이블: {config.useCustomTable ? config.customTableName : config.selectedTable || "(미설정)"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러 상태
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 없음
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||||
|
{config.emptyMessage || "데이터가 없습니다."}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="v2-table-grouped flex flex-col"
|
||||||
|
style={{
|
||||||
|
height: config.height,
|
||||||
|
maxHeight: config.maxHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 툴바 */}
|
||||||
|
{config.showExpandAllButton && (
|
||||||
|
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={expandAll}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<UnfoldVertical className="mr-1 h-3 w-3" />
|
||||||
|
전체 펼치기
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={collapseAll}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<FoldVertical className="mr-1 h-3 w-3" />
|
||||||
|
전체 접기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{groupCount}개 그룹 | 총 {totalCount}건
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<table className="w-full border-collapse text-sm">
|
||||||
|
{/* 테이블 헤더 */}
|
||||||
|
<thead className="sticky top-0 z-10 bg-muted">
|
||||||
|
<tr>
|
||||||
|
{/* 전체 선택 체크박스 */}
|
||||||
|
{config.showCheckbox && (
|
||||||
|
<th className="w-10 whitespace-nowrap border-b px-3 py-2 text-left">
|
||||||
|
<Checkbox
|
||||||
|
checked={isAllSelected}
|
||||||
|
onCheckedChange={toggleAllSelection}
|
||||||
|
className={cn(isIndeterminate && "data-[state=checked]:bg-muted")}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{/* 컬럼 헤더 */}
|
||||||
|
{visibleColumns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col.columnName}
|
||||||
|
className={cn(
|
||||||
|
"whitespace-nowrap border-b px-3 py-2 font-medium text-muted-foreground",
|
||||||
|
col.align === "center" && "text-center",
|
||||||
|
col.align === "right" && "text-right"
|
||||||
|
)}
|
||||||
|
style={{ width: col.width ? `${col.width}px` : "auto" }}
|
||||||
|
>
|
||||||
|
{col.displayName || col.columnName}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
{/* 테이블 바디 */}
|
||||||
|
<tbody>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<React.Fragment key={group.groupKey}>
|
||||||
|
{/* 그룹 헤더 */}
|
||||||
|
<GroupHeader
|
||||||
|
group={group}
|
||||||
|
config={config}
|
||||||
|
onToggle={() => handleGroupToggle(group.groupKey)}
|
||||||
|
onSelectToggle={
|
||||||
|
config.showCheckbox
|
||||||
|
? () => toggleGroupSelection(group.groupKey)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={config.groupHeaderStyle}
|
||||||
|
columnCount={totalColumnCount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 그룹 아이템 (펼쳐진 경우만) */}
|
||||||
|
{group.expanded &&
|
||||||
|
group.items.map((item, idx) => {
|
||||||
|
const itemId = getItemId(item);
|
||||||
|
const isSelected = group.selectedItemIds?.includes(itemId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={itemId}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors",
|
||||||
|
config.rowClickable && "cursor-pointer hover:bg-muted/50",
|
||||||
|
isSelected && "bg-primary/10"
|
||||||
|
)}
|
||||||
|
onClick={() => handleRowClick(item, group.groupKey, idx)}
|
||||||
|
>
|
||||||
|
{/* 체크박스 */}
|
||||||
|
{config.showCheckbox && (
|
||||||
|
<td
|
||||||
|
className="px-3 py-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
toggleItemSelection(group.groupKey, itemId)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 데이터 컬럼 */}
|
||||||
|
{visibleColumns.map((col) => {
|
||||||
|
const value = item[col.columnName];
|
||||||
|
let displayValue: React.ReactNode = value;
|
||||||
|
|
||||||
|
// 포맷 적용
|
||||||
|
if (col.format === "number" && typeof value === "number") {
|
||||||
|
displayValue = value.toLocaleString();
|
||||||
|
} else if (col.format === "currency" && typeof value === "number") {
|
||||||
|
displayValue = `₩${value.toLocaleString()}`;
|
||||||
|
} else if (col.format === "date" && value) {
|
||||||
|
displayValue = new Date(value).toLocaleDateString("ko-KR");
|
||||||
|
} else if (col.format === "boolean") {
|
||||||
|
displayValue = value ? "예" : "아니오";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={col.columnName}
|
||||||
|
className={cn(
|
||||||
|
"px-3 py-2",
|
||||||
|
col.align === "center" && "text-center",
|
||||||
|
col.align === "right" && "text-right"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayValue ?? "-"}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TableGroupedComponent;
|
||||||
|
|
@ -0,0 +1,717 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { TableGroupedConfig, ColumnConfig, LinkedFilterConfig } from "./types";
|
||||||
|
import {
|
||||||
|
groupHeaderStyleOptions,
|
||||||
|
checkboxModeOptions,
|
||||||
|
sortDirectionOptions,
|
||||||
|
} from "./config";
|
||||||
|
import { Trash2, Plus } from "lucide-react";
|
||||||
|
|
||||||
|
interface TableGroupedConfigPanelProps {
|
||||||
|
config: TableGroupedConfig;
|
||||||
|
onChange: (newConfig: Partial<TableGroupedConfig>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2-table-grouped 설정 패널
|
||||||
|
*/
|
||||||
|
// 테이블 정보 타입
|
||||||
|
interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableGroupedConfigPanel({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}: TableGroupedConfigPanelProps) {
|
||||||
|
// 테이블 목록 (라벨명 포함)
|
||||||
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
|
const [tableColumns, setTableColumns] = useState<ColumnConfig[]>([]);
|
||||||
|
const [loadingTables, setLoadingTables] = useState(false);
|
||||||
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||||
|
const [tableSelectOpen, setTableSelectOpen] = useState(false);
|
||||||
|
|
||||||
|
// 테이블 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTables = async () => {
|
||||||
|
setLoadingTables(true);
|
||||||
|
try {
|
||||||
|
const tableList = await tableTypeApi.getTables();
|
||||||
|
if (tableList && Array.isArray(tableList)) {
|
||||||
|
setTables(
|
||||||
|
tableList.map((t: any) => ({
|
||||||
|
tableName: t.tableName || t.table_name,
|
||||||
|
displayName: t.displayName || t.display_name || t.tableName || t.table_name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("테이블 목록 로드 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingTables(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadTables();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 선택된 테이블의 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const tableName = config.useCustomTable
|
||||||
|
? config.customTableName
|
||||||
|
: config.selectedTable;
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
setTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadColumns = async () => {
|
||||||
|
setLoadingColumns(true);
|
||||||
|
try {
|
||||||
|
const columns = await tableTypeApi.getColumns(tableName);
|
||||||
|
if (columns && Array.isArray(columns)) {
|
||||||
|
const cols: ColumnConfig[] = columns.map(
|
||||||
|
(col: any, idx: number) => ({
|
||||||
|
columnName: col.column_name || col.columnName,
|
||||||
|
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
|
||||||
|
visible: true,
|
||||||
|
sortable: true,
|
||||||
|
searchable: false,
|
||||||
|
align: "left" as const,
|
||||||
|
order: idx,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setTableColumns(cols);
|
||||||
|
|
||||||
|
// 컬럼 설정이 없으면 자동 설정
|
||||||
|
if (!config.columns || config.columns.length === 0) {
|
||||||
|
onChange({ ...config, columns: cols });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("컬럼 로드 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setLoadingColumns(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadColumns();
|
||||||
|
}, [config.selectedTable, config.customTableName, config.useCustomTable]);
|
||||||
|
|
||||||
|
// 설정 업데이트 헬퍼
|
||||||
|
const updateConfig = (updates: Partial<TableGroupedConfig>) => {
|
||||||
|
onChange({ ...config, ...updates });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 그룹 설정 업데이트 헬퍼
|
||||||
|
const updateGroupConfig = (
|
||||||
|
updates: Partial<TableGroupedConfig["groupConfig"]>
|
||||||
|
) => {
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
groupConfig: { ...config.groupConfig, ...updates },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컬럼 가시성 토글
|
||||||
|
const toggleColumnVisibility = (columnName: string) => {
|
||||||
|
const updatedColumns = (config.columns || []).map((col) =>
|
||||||
|
col.columnName === columnName ? { ...col, visible: !col.visible } : col
|
||||||
|
);
|
||||||
|
updateConfig({ columns: updatedColumns });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 합계 컬럼 토글
|
||||||
|
const toggleSumColumn = (columnName: string) => {
|
||||||
|
const currentSumCols = config.groupConfig?.summary?.sumColumns || [];
|
||||||
|
const newSumCols = currentSumCols.includes(columnName)
|
||||||
|
? currentSumCols.filter((c) => c !== columnName)
|
||||||
|
: [...currentSumCols, columnName];
|
||||||
|
|
||||||
|
updateGroupConfig({
|
||||||
|
summary: {
|
||||||
|
...config.groupConfig?.summary,
|
||||||
|
sumColumns: newSumCols,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연결 필터 추가
|
||||||
|
const addLinkedFilter = () => {
|
||||||
|
const newFilter: LinkedFilterConfig = {
|
||||||
|
sourceComponentId: "",
|
||||||
|
sourceField: "value",
|
||||||
|
targetColumn: "",
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
updateConfig({
|
||||||
|
linkedFilters: [...(config.linkedFilters || []), newFilter],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연결 필터 제거
|
||||||
|
const removeLinkedFilter = (index: number) => {
|
||||||
|
const filters = [...(config.linkedFilters || [])];
|
||||||
|
filters.splice(index, 1);
|
||||||
|
updateConfig({ linkedFilters: filters });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 연결 필터 업데이트
|
||||||
|
const updateLinkedFilter = (
|
||||||
|
index: number,
|
||||||
|
updates: Partial<LinkedFilterConfig>
|
||||||
|
) => {
|
||||||
|
const filters = [...(config.linkedFilters || [])];
|
||||||
|
filters[index] = { ...filters[index], ...updates };
|
||||||
|
updateConfig({ linkedFilters: filters });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<Accordion type="multiple" defaultValue={["table", "group", "display"]}>
|
||||||
|
{/* 테이블 설정 */}
|
||||||
|
<AccordionItem value="table">
|
||||||
|
<AccordionTrigger className="text-sm font-medium">
|
||||||
|
테이블 설정
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3 pt-2">
|
||||||
|
{/* 커스텀 테이블 사용 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">커스텀 테이블 사용</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.useCustomTable}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateConfig({ useCustomTable: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 선택 */}
|
||||||
|
{config.useCustomTable ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">커스텀 테이블명</Label>
|
||||||
|
<Input
|
||||||
|
value={config.customTableName || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ customTableName: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="테이블명 입력"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">테이블 선택</Label>
|
||||||
|
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableSelectOpen}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loadingTables}
|
||||||
|
>
|
||||||
|
{loadingTables ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||||
|
로딩 중...
|
||||||
|
</>
|
||||||
|
) : config.selectedTable ? (
|
||||||
|
<span className="truncate">
|
||||||
|
{tables.find((t) => t.tableName === config.selectedTable)
|
||||||
|
?.displayName || config.selectedTable}
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
({config.selectedTable})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"테이블 검색..."
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command
|
||||||
|
filter={(value, search) => {
|
||||||
|
// 테이블명 또는 라벨명에 검색어가 포함되면 1, 아니면 0
|
||||||
|
const lowerSearch = search.toLowerCase();
|
||||||
|
if (value.toLowerCase().includes(lowerSearch)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="테이블명 또는 라벨 검색..."
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">
|
||||||
|
테이블을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.displayName} ${table.tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
updateConfig({ selectedTable: table.tableName });
|
||||||
|
setTableSelectOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.selectedTable === table.tableName
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{table.displayName}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{table.tableName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* 그룹화 설정 */}
|
||||||
|
<AccordionItem value="group">
|
||||||
|
<AccordionTrigger className="text-sm font-medium">
|
||||||
|
그룹화 설정
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3 pt-2">
|
||||||
|
{/* 그룹화 기준 컬럼 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">그룹화 기준 컬럼 *</Label>
|
||||||
|
<Select
|
||||||
|
value={config.groupConfig?.groupByColumn || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateGroupConfig({ groupByColumn: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹 라벨 형식 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">그룹 라벨 형식</Label>
|
||||||
|
<Input
|
||||||
|
value={config.groupConfig?.groupLabelFormat || "{value}"}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateGroupConfig({ groupLabelFormat: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="{value} ({컬럼명})"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{"{value}"} = 그룹값, {"{컬럼명}"} = 해당 컬럼 값
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기본 펼침 상태 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">기본 펼침 상태</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.groupConfig?.defaultExpanded ?? true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateGroupConfig({ defaultExpanded: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹 정렬 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">그룹 정렬</Label>
|
||||||
|
<Select
|
||||||
|
value={config.groupConfig?.sortDirection || "asc"}
|
||||||
|
onValueChange={(value: "asc" | "desc") =>
|
||||||
|
updateGroupConfig({ sortDirection: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sortDirectionOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 개수 표시 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">개수 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.groupConfig?.summary?.showCount ?? true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateGroupConfig({
|
||||||
|
summary: {
|
||||||
|
...config.groupConfig?.summary,
|
||||||
|
showCount: checked,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 합계 컬럼 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">합계 표시 컬럼</Label>
|
||||||
|
<div className="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<div
|
||||||
|
key={col.columnName}
|
||||||
|
className="flex items-center gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`sum-${col.columnName}`}
|
||||||
|
checked={
|
||||||
|
config.groupConfig?.summary?.sumColumns?.includes(
|
||||||
|
col.columnName
|
||||||
|
) ?? false
|
||||||
|
}
|
||||||
|
onCheckedChange={() => toggleSumColumn(col.columnName)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`sum-${col.columnName}`}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{col.displayName || col.columnName}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* 표시 설정 */}
|
||||||
|
<AccordionItem value="display">
|
||||||
|
<AccordionTrigger className="text-sm font-medium">
|
||||||
|
표시 설정
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3 pt-2">
|
||||||
|
{/* 체크박스 표시 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">체크박스 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.showCheckbox}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateConfig({ showCheckbox: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 체크박스 모드 */}
|
||||||
|
{config.showCheckbox && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">선택 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.checkboxMode || "multi"}
|
||||||
|
onValueChange={(value: "single" | "multi") =>
|
||||||
|
updateConfig({ checkboxMode: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{checkboxModeOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 그룹 헤더 스타일 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">그룹 헤더 스타일</Label>
|
||||||
|
<Select
|
||||||
|
value={config.groupHeaderStyle || "default"}
|
||||||
|
onValueChange={(value: "default" | "compact" | "card") =>
|
||||||
|
updateConfig({ groupHeaderStyle: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{groupHeaderStyleOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 전체 펼치기/접기 버튼 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">펼치기/접기 버튼 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.showExpandAllButton ?? true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateConfig({ showExpandAllButton: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 행 클릭 가능 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">행 클릭 가능</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.rowClickable ?? true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateConfig({ rowClickable: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최대 높이 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">최대 높이 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.maxHeight || 600}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ maxHeight: parseInt(e.target.value) || 600 })
|
||||||
|
}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 빈 데이터 메시지 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">빈 데이터 메시지</Label>
|
||||||
|
<Input
|
||||||
|
value={config.emptyMessage || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ emptyMessage: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="데이터가 없습니다."
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* 컬럼 설정 */}
|
||||||
|
<AccordionItem value="columns">
|
||||||
|
<AccordionTrigger className="text-sm font-medium">
|
||||||
|
컬럼 설정
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-2 pt-2">
|
||||||
|
<div className="max-h-48 space-y-1 overflow-y-auto rounded border p-2">
|
||||||
|
{(config.columns || tableColumns).map((col) => (
|
||||||
|
<div
|
||||||
|
key={col.columnName}
|
||||||
|
className="flex items-center gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`col-${col.columnName}`}
|
||||||
|
checked={col.visible !== false}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
toggleColumnVisibility(col.columnName)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`col-${col.columnName}`}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{col.displayName || col.columnName}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* 연동 설정 */}
|
||||||
|
<AccordionItem value="linked">
|
||||||
|
<AccordionTrigger className="text-sm font-medium">
|
||||||
|
연동 설정
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3 pt-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">연결 필터</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addLinkedFilter}
|
||||||
|
className="h-6 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(config.linkedFilters || []).length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
연결된 필터가 없습니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(config.linkedFilters || []).map((filter, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="space-y-2 rounded border p-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
필터 #{idx + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={filter.enabled !== false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateLinkedFilter(idx, { enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => removeLinkedFilter(idx)}
|
||||||
|
className="h-6 w-6 p-0 text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">소스 컴포넌트 ID</Label>
|
||||||
|
<Input
|
||||||
|
value={filter.sourceComponentId}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLinkedFilter(idx, {
|
||||||
|
sourceComponentId: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="예: search-filter-1"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">소스 필드</Label>
|
||||||
|
<Input
|
||||||
|
value={filter.sourceField || "value"}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLinkedFilter(idx, {
|
||||||
|
sourceField: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="value"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">대상 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={filter.targetColumn}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateLinkedFilter(idx, { targetColumn: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem
|
||||||
|
key={col.columnName}
|
||||||
|
value={col.columnName}
|
||||||
|
>
|
||||||
|
{col.displayName || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
다른 컴포넌트(검색필터 등)의 선택 값으로 이 테이블을 필터링합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TableGroupedConfigPanel;
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
|
import { V2TableGroupedDefinition } from "./index";
|
||||||
|
import { TableGroupedComponent } from "./TableGroupedComponent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TableGrouped 렌더러
|
||||||
|
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||||
|
*/
|
||||||
|
export class TableGroupedRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = V2TableGroupedDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<TableGroupedComponent
|
||||||
|
{...this.props}
|
||||||
|
config={this.props.component?.componentConfig || {}}
|
||||||
|
isDesignMode={this.props.isDesignMode}
|
||||||
|
formData={this.props.formData}
|
||||||
|
componentId={this.props.component?.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설정 변경 핸들러
|
||||||
|
protected handleConfigChange = (config: any) => {
|
||||||
|
console.log("📥 TableGroupedRenderer에서 설정 변경 받음:", config);
|
||||||
|
|
||||||
|
// 상위 컴포넌트의 onConfigChange 호출 (화면 설계자에게 알림)
|
||||||
|
if (this.props.onConfigChange) {
|
||||||
|
this.props.onConfigChange(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateComponent({ config });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 값 변경 처리
|
||||||
|
protected handleValueChange = (value: any) => {
|
||||||
|
this.updateComponent({ value });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 등록 실행
|
||||||
|
TableGroupedRenderer.registerSelf();
|
||||||
|
|
||||||
|
// 강제 등록 (디버깅용)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
TableGroupedRenderer.registerSelf();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ TableGrouped 강제 등록 실패:", error);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ChevronDown, ChevronRight, Minus } from "lucide-react";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { GroupState, TableGroupedConfig } from "../types";
|
||||||
|
|
||||||
|
interface GroupHeaderProps {
|
||||||
|
/** 그룹 상태 */
|
||||||
|
group: GroupState;
|
||||||
|
/** 설정 */
|
||||||
|
config: TableGroupedConfig;
|
||||||
|
/** 그룹 토글 핸들러 */
|
||||||
|
onToggle: () => void;
|
||||||
|
/** 그룹 선택 토글 핸들러 */
|
||||||
|
onSelectToggle?: () => void;
|
||||||
|
/** 그룹 헤더 스타일 */
|
||||||
|
style?: "default" | "compact" | "card";
|
||||||
|
/** 컬럼 개수 (colspan용) */
|
||||||
|
columnCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 헤더 컴포넌트
|
||||||
|
* 그룹 펼치기/접기, 체크박스, 요약 정보 표시
|
||||||
|
*/
|
||||||
|
export function GroupHeader({
|
||||||
|
group,
|
||||||
|
config,
|
||||||
|
onToggle,
|
||||||
|
onSelectToggle,
|
||||||
|
style = "default",
|
||||||
|
columnCount = 1,
|
||||||
|
}: GroupHeaderProps) {
|
||||||
|
const { showCheckbox } = config;
|
||||||
|
const { summary } = group;
|
||||||
|
|
||||||
|
// 일부 선택 여부
|
||||||
|
const isIndeterminate =
|
||||||
|
group.selectedItemIds &&
|
||||||
|
group.selectedItemIds.length > 0 &&
|
||||||
|
group.selectedItemIds.length < group.items.length;
|
||||||
|
|
||||||
|
// 요약 텍스트 생성
|
||||||
|
const summaryText = React.useMemo(() => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// 개수
|
||||||
|
if (config.groupConfig?.summary?.showCount !== false) {
|
||||||
|
parts.push(`${summary.count}건`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 합계
|
||||||
|
if (summary.sum) {
|
||||||
|
for (const [col, value] of Object.entries(summary.sum)) {
|
||||||
|
const displayName =
|
||||||
|
config.columns?.find((c) => c.columnName === col)?.displayName || col;
|
||||||
|
parts.push(`${displayName}: ${value.toLocaleString()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(" | ");
|
||||||
|
}, [summary, config]);
|
||||||
|
|
||||||
|
// 스타일별 클래스
|
||||||
|
const headerClasses = cn(
|
||||||
|
"flex items-center gap-2 cursor-pointer select-none transition-colors",
|
||||||
|
{
|
||||||
|
// default 스타일
|
||||||
|
"px-3 py-2 bg-muted/50 hover:bg-muted border-b": style === "default",
|
||||||
|
// compact 스타일
|
||||||
|
"px-2 py-1 bg-muted/30 hover:bg-muted/50 border-b text-sm":
|
||||||
|
style === "compact",
|
||||||
|
// card 스타일
|
||||||
|
"px-4 py-3 bg-card border rounded-t-lg shadow-sm hover:shadow":
|
||||||
|
style === "card",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="group-header-row">
|
||||||
|
<td
|
||||||
|
colSpan={columnCount}
|
||||||
|
className="p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
// 체크박스 클릭 시 토글 방지
|
||||||
|
if ((e.target as HTMLElement).closest('[data-checkbox="true"]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onToggle();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={headerClasses}>
|
||||||
|
{/* 펼치기/접기 아이콘 */}
|
||||||
|
<span className="flex-shrink-0 text-muted-foreground">
|
||||||
|
{group.expanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 체크박스 */}
|
||||||
|
{showCheckbox && onSelectToggle && (
|
||||||
|
<span
|
||||||
|
data-checkbox="true"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSelectToggle();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={group.selected}
|
||||||
|
className={cn(
|
||||||
|
"data-[state=checked]:bg-primary",
|
||||||
|
isIndeterminate && "data-[state=checked]:bg-muted"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isIndeterminate && (
|
||||||
|
<Minus className="absolute h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 그룹 라벨 */}
|
||||||
|
<span className="font-medium text-foreground">{group.groupLabel}</span>
|
||||||
|
|
||||||
|
{/* 요약 정보 */}
|
||||||
|
{summaryText && (
|
||||||
|
<span className="ml-auto text-sm text-muted-foreground">
|
||||||
|
{summaryText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GroupHeader;
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { TableGroupedConfig } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2-table-grouped 기본 설정값
|
||||||
|
*/
|
||||||
|
export const defaultTableGroupedConfig: Partial<TableGroupedConfig> = {
|
||||||
|
// 그룹화 기본 설정
|
||||||
|
groupConfig: {
|
||||||
|
groupByColumn: "",
|
||||||
|
groupLabelFormat: "{value}",
|
||||||
|
defaultExpanded: true,
|
||||||
|
sortDirection: "asc",
|
||||||
|
summary: {
|
||||||
|
showCount: true,
|
||||||
|
sumColumns: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 체크박스 기본 설정
|
||||||
|
showCheckbox: false,
|
||||||
|
checkboxMode: "multi",
|
||||||
|
|
||||||
|
// 페이지네이션 기본 설정
|
||||||
|
pagination: {
|
||||||
|
enabled: false,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
// UI 기본 설정
|
||||||
|
isReadOnly: false,
|
||||||
|
rowClickable: true,
|
||||||
|
showExpandAllButton: true,
|
||||||
|
groupHeaderStyle: "default",
|
||||||
|
emptyMessage: "데이터가 없습니다.",
|
||||||
|
|
||||||
|
// 높이 기본 설정
|
||||||
|
height: "auto",
|
||||||
|
maxHeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 헤더 스타일 옵션
|
||||||
|
*/
|
||||||
|
export const groupHeaderStyleOptions = [
|
||||||
|
{ value: "default", label: "기본" },
|
||||||
|
{ value: "compact", label: "컴팩트" },
|
||||||
|
{ value: "card", label: "카드" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 체크박스 모드 옵션
|
||||||
|
*/
|
||||||
|
export const checkboxModeOptions = [
|
||||||
|
{ value: "single", label: "단일 선택" },
|
||||||
|
{ value: "multi", label: "다중 선택" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정렬 방향 옵션
|
||||||
|
*/
|
||||||
|
export const sortDirectionOptions = [
|
||||||
|
{ value: "asc", label: "오름차순" },
|
||||||
|
{ value: "desc", label: "내림차순" },
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,389 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
TableGroupedConfig,
|
||||||
|
GroupState,
|
||||||
|
GroupSummary,
|
||||||
|
UseGroupedDataResult,
|
||||||
|
} from "../types";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 요약 데이터 계산
|
||||||
|
*/
|
||||||
|
function calculateSummary(
|
||||||
|
items: any[],
|
||||||
|
config: TableGroupedConfig
|
||||||
|
): GroupSummary {
|
||||||
|
const summary: GroupSummary = {
|
||||||
|
count: items.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const summaryConfig = config.groupConfig?.summary;
|
||||||
|
if (!summaryConfig) return summary;
|
||||||
|
|
||||||
|
// 합계 계산
|
||||||
|
if (summaryConfig.sumColumns && summaryConfig.sumColumns.length > 0) {
|
||||||
|
summary.sum = {};
|
||||||
|
for (const col of summaryConfig.sumColumns) {
|
||||||
|
summary.sum[col] = items.reduce((acc, item) => {
|
||||||
|
const val = parseFloat(item[col]);
|
||||||
|
return acc + (isNaN(val) ? 0 : val);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 평균 계산
|
||||||
|
if (summaryConfig.avgColumns && summaryConfig.avgColumns.length > 0) {
|
||||||
|
summary.avg = {};
|
||||||
|
for (const col of summaryConfig.avgColumns) {
|
||||||
|
const validItems = items.filter(
|
||||||
|
(item) => item[col] !== null && item[col] !== undefined
|
||||||
|
);
|
||||||
|
const sum = validItems.reduce((acc, item) => {
|
||||||
|
const val = parseFloat(item[col]);
|
||||||
|
return acc + (isNaN(val) ? 0 : val);
|
||||||
|
}, 0);
|
||||||
|
summary.avg[col] = validItems.length > 0 ? sum / validItems.length : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최대값 계산
|
||||||
|
if (summaryConfig.maxColumns && summaryConfig.maxColumns.length > 0) {
|
||||||
|
summary.max = {};
|
||||||
|
for (const col of summaryConfig.maxColumns) {
|
||||||
|
const values = items
|
||||||
|
.map((item) => parseFloat(item[col]))
|
||||||
|
.filter((v) => !isNaN(v));
|
||||||
|
summary.max[col] = values.length > 0 ? Math.max(...values) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최소값 계산
|
||||||
|
if (summaryConfig.minColumns && summaryConfig.minColumns.length > 0) {
|
||||||
|
summary.min = {};
|
||||||
|
for (const col of summaryConfig.minColumns) {
|
||||||
|
const values = items
|
||||||
|
.map((item) => parseFloat(item[col]))
|
||||||
|
.filter((v) => !isNaN(v));
|
||||||
|
summary.min[col] = values.length > 0 ? Math.min(...values) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 라벨 포맷팅
|
||||||
|
*/
|
||||||
|
function formatGroupLabel(
|
||||||
|
groupValue: any,
|
||||||
|
item: any,
|
||||||
|
format?: string
|
||||||
|
): string {
|
||||||
|
if (!format) {
|
||||||
|
return String(groupValue ?? "(빈 값)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// {value}를 그룹 값으로 치환
|
||||||
|
let label = format.replace("{value}", String(groupValue ?? "(빈 값)"));
|
||||||
|
|
||||||
|
// {컬럼명} 패턴을 해당 컬럼 값으로 치환
|
||||||
|
const columnPattern = /\{([^}]+)\}/g;
|
||||||
|
label = label.replace(columnPattern, (match, columnName) => {
|
||||||
|
if (columnName === "value") return String(groupValue ?? "");
|
||||||
|
return String(item?.[columnName] ?? "");
|
||||||
|
});
|
||||||
|
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터를 그룹화하는 훅
|
||||||
|
*/
|
||||||
|
export function useGroupedData(
|
||||||
|
config: TableGroupedConfig,
|
||||||
|
externalData?: any[],
|
||||||
|
searchFilters?: Record<string, any>
|
||||||
|
): UseGroupedDataResult {
|
||||||
|
// 원본 데이터
|
||||||
|
const [rawData, setRawData] = useState<any[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 그룹 펼침 상태 관리
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||||
|
// 사용자가 수동으로 펼침/접기를 조작했는지 여부
|
||||||
|
const [isManuallyControlled, setIsManuallyControlled] = useState(false);
|
||||||
|
|
||||||
|
// 선택 상태 관리
|
||||||
|
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(
|
||||||
|
new Set()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 테이블명 결정
|
||||||
|
const tableName = config.useCustomTable
|
||||||
|
? config.customTableName
|
||||||
|
: config.selectedTable;
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (externalData) {
|
||||||
|
setRawData(externalData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
setRawData([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${tableName}/data`,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
size: 10000, // 그룹화를 위해 전체 데이터 로드
|
||||||
|
autoFilter: true,
|
||||||
|
search: searchFilters || {},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData = response.data?.data?.data || response.data?.data || [];
|
||||||
|
setRawData(Array.isArray(responseData) ? responseData : []);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "데이터 로드 중 오류 발생");
|
||||||
|
setRawData([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [tableName, externalData, searchFilters]);
|
||||||
|
|
||||||
|
// 초기 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
// 외부 데이터 변경 시 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalData) {
|
||||||
|
setRawData(externalData);
|
||||||
|
}
|
||||||
|
}, [externalData]);
|
||||||
|
|
||||||
|
// 그룹화된 데이터 계산
|
||||||
|
const groups = useMemo((): GroupState[] => {
|
||||||
|
const groupByColumn = config.groupConfig?.groupByColumn;
|
||||||
|
if (!groupByColumn || rawData.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터를 그룹별로 분류
|
||||||
|
const groupMap = new Map<string, any[]>();
|
||||||
|
|
||||||
|
for (const item of rawData) {
|
||||||
|
const groupValue = item[groupByColumn];
|
||||||
|
const groupKey = String(groupValue ?? "__null__");
|
||||||
|
|
||||||
|
if (!groupMap.has(groupKey)) {
|
||||||
|
groupMap.set(groupKey, []);
|
||||||
|
}
|
||||||
|
groupMap.get(groupKey)!.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 배열 생성
|
||||||
|
const groupArray: GroupState[] = [];
|
||||||
|
const defaultExpanded = config.groupConfig?.defaultExpanded ?? true;
|
||||||
|
|
||||||
|
for (const [groupKey, items] of groupMap.entries()) {
|
||||||
|
const firstItem = items[0];
|
||||||
|
const groupValue =
|
||||||
|
groupKey === "__null__" ? null : firstItem[groupByColumn];
|
||||||
|
|
||||||
|
// 펼침 상태 결정: 수동 조작 전에는 defaultExpanded, 수동 조작 후에는 expandedGroups 참조
|
||||||
|
const isExpanded = isManuallyControlled
|
||||||
|
? expandedGroups.has(groupKey)
|
||||||
|
: defaultExpanded;
|
||||||
|
|
||||||
|
groupArray.push({
|
||||||
|
groupKey,
|
||||||
|
groupLabel: formatGroupLabel(
|
||||||
|
groupValue,
|
||||||
|
firstItem,
|
||||||
|
config.groupConfig?.groupLabelFormat
|
||||||
|
),
|
||||||
|
expanded: isExpanded,
|
||||||
|
items,
|
||||||
|
summary: calculateSummary(items, config),
|
||||||
|
selected: items.every((item) =>
|
||||||
|
selectedItemIds.has(getItemId(item, config))
|
||||||
|
),
|
||||||
|
selectedItemIds: items
|
||||||
|
.filter((item) => selectedItemIds.has(getItemId(item, config)))
|
||||||
|
.map((item) => getItemId(item, config)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
const sortDirection = config.groupConfig?.sortDirection ?? "asc";
|
||||||
|
groupArray.sort((a, b) => {
|
||||||
|
const comparison = a.groupLabel.localeCompare(b.groupLabel, "ko");
|
||||||
|
return sortDirection === "asc" ? comparison : -comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
return groupArray;
|
||||||
|
}, [rawData, config, expandedGroups, selectedItemIds, isManuallyControlled]);
|
||||||
|
|
||||||
|
// 아이템 ID 추출
|
||||||
|
function getItemId(item: any, cfg: TableGroupedConfig): string {
|
||||||
|
// id 또는 첫 번째 컬럼을 ID로 사용
|
||||||
|
if (item.id !== undefined) return String(item.id);
|
||||||
|
const firstCol = cfg.columns?.[0]?.columnName;
|
||||||
|
if (firstCol && item[firstCol] !== undefined) return String(item[firstCol]);
|
||||||
|
return JSON.stringify(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 그룹 토글
|
||||||
|
const toggleGroup = useCallback((groupKey: string) => {
|
||||||
|
setIsManuallyControlled(true);
|
||||||
|
setExpandedGroups((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(groupKey)) {
|
||||||
|
next.delete(groupKey);
|
||||||
|
} else {
|
||||||
|
next.add(groupKey);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 전체 펼치기
|
||||||
|
const expandAll = useCallback(() => {
|
||||||
|
setIsManuallyControlled(true);
|
||||||
|
setExpandedGroups(new Set(groups.map((g) => g.groupKey)));
|
||||||
|
}, [groups]);
|
||||||
|
|
||||||
|
// 전체 접기
|
||||||
|
const collapseAll = useCallback(() => {
|
||||||
|
setIsManuallyControlled(true);
|
||||||
|
setExpandedGroups(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 아이템 선택 토글
|
||||||
|
const toggleItemSelection = useCallback(
|
||||||
|
(groupKey: string, itemId: string) => {
|
||||||
|
setSelectedItemIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(itemId)) {
|
||||||
|
next.delete(itemId);
|
||||||
|
} else {
|
||||||
|
// 단일 선택 모드
|
||||||
|
if (config.checkboxMode === "single") {
|
||||||
|
next.clear();
|
||||||
|
}
|
||||||
|
next.add(itemId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[config.checkboxMode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 그룹 전체 선택 토글
|
||||||
|
const toggleGroupSelection = useCallback(
|
||||||
|
(groupKey: string) => {
|
||||||
|
const group = groups.find((g) => g.groupKey === groupKey);
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
setSelectedItemIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
const groupItemIds = group.items.map((item) => getItemId(item, config));
|
||||||
|
const allSelected = groupItemIds.every((id) => next.has(id));
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
// 전체 해제
|
||||||
|
for (const id of groupItemIds) {
|
||||||
|
next.delete(id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 전체 선택
|
||||||
|
if (config.checkboxMode === "single") {
|
||||||
|
next.clear();
|
||||||
|
next.add(groupItemIds[0]);
|
||||||
|
} else {
|
||||||
|
for (const id of groupItemIds) {
|
||||||
|
next.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[groups, config]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전체 선택 토글
|
||||||
|
const toggleAllSelection = useCallback(() => {
|
||||||
|
const allItemIds = rawData.map((item) => getItemId(item, config));
|
||||||
|
const allSelected = allItemIds.every((id) => selectedItemIds.has(id));
|
||||||
|
|
||||||
|
if (allSelected) {
|
||||||
|
setSelectedItemIds(new Set());
|
||||||
|
} else {
|
||||||
|
if (config.checkboxMode === "single" && allItemIds.length > 0) {
|
||||||
|
setSelectedItemIds(new Set([allItemIds[0]]));
|
||||||
|
} else {
|
||||||
|
setSelectedItemIds(new Set(allItemIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [rawData, config, selectedItemIds]);
|
||||||
|
|
||||||
|
// 선택된 아이템 목록
|
||||||
|
const selectedItems = useMemo(() => {
|
||||||
|
return rawData.filter((item) =>
|
||||||
|
selectedItemIds.has(getItemId(item, config))
|
||||||
|
);
|
||||||
|
}, [rawData, selectedItemIds, config]);
|
||||||
|
|
||||||
|
// 모두 선택 여부
|
||||||
|
const isAllSelected = useMemo(() => {
|
||||||
|
if (rawData.length === 0) return false;
|
||||||
|
return rawData.every((item) =>
|
||||||
|
selectedItemIds.has(getItemId(item, config))
|
||||||
|
);
|
||||||
|
}, [rawData, selectedItemIds, config]);
|
||||||
|
|
||||||
|
// 일부 선택 여부
|
||||||
|
const isIndeterminate = useMemo(() => {
|
||||||
|
if (rawData.length === 0) return false;
|
||||||
|
const selectedCount = rawData.filter((item) =>
|
||||||
|
selectedItemIds.has(getItemId(item, config))
|
||||||
|
).length;
|
||||||
|
return selectedCount > 0 && selectedCount < rawData.length;
|
||||||
|
}, [rawData, selectedItemIds, config]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
toggleGroup,
|
||||||
|
expandAll,
|
||||||
|
collapseAll,
|
||||||
|
toggleItemSelection,
|
||||||
|
toggleGroupSelection,
|
||||||
|
toggleAllSelection,
|
||||||
|
selectedItems,
|
||||||
|
isAllSelected,
|
||||||
|
isIndeterminate,
|
||||||
|
refresh: fetchData,
|
||||||
|
rawData,
|
||||||
|
totalCount: rawData.length,
|
||||||
|
groupCount: groups.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useGroupedData;
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import type { WebType } from "@/types/screen";
|
||||||
|
import { TableGroupedComponent } from "./TableGroupedComponent";
|
||||||
|
import { TableGroupedConfigPanel } from "./TableGroupedConfigPanel";
|
||||||
|
import { TableGroupedConfig } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* V2 그룹화 테이블 컴포넌트 Definition
|
||||||
|
*
|
||||||
|
* 데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공합니다.
|
||||||
|
* v2-table-list를 기반으로 확장된 컴포넌트입니다.
|
||||||
|
*/
|
||||||
|
export const V2TableGroupedDefinition = createComponentDefinition({
|
||||||
|
id: "v2-table-grouped",
|
||||||
|
name: "그룹화 테이블",
|
||||||
|
nameEng: "Grouped Table Component",
|
||||||
|
description: "데이터를 그룹화하여 접기/펼치기 기능을 제공하는 테이블",
|
||||||
|
category: ComponentCategory.DISPLAY,
|
||||||
|
webType: "text",
|
||||||
|
component: TableGroupedComponent,
|
||||||
|
defaultConfig: {
|
||||||
|
// 테이블 설정
|
||||||
|
selectedTable: "",
|
||||||
|
useCustomTable: false,
|
||||||
|
customTableName: "",
|
||||||
|
|
||||||
|
// 그룹화 설정
|
||||||
|
groupConfig: {
|
||||||
|
groupByColumn: "",
|
||||||
|
groupLabelFormat: "{value}",
|
||||||
|
defaultExpanded: true,
|
||||||
|
sortDirection: "asc",
|
||||||
|
summary: {
|
||||||
|
showCount: true,
|
||||||
|
sumColumns: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// 컬럼 설정
|
||||||
|
columns: [],
|
||||||
|
|
||||||
|
// 체크박스 설정
|
||||||
|
showCheckbox: false,
|
||||||
|
checkboxMode: "multi",
|
||||||
|
|
||||||
|
// 페이지네이션 설정
|
||||||
|
pagination: {
|
||||||
|
enabled: false,
|
||||||
|
pageSize: 10,
|
||||||
|
},
|
||||||
|
|
||||||
|
// UI 설정
|
||||||
|
isReadOnly: false,
|
||||||
|
rowClickable: true,
|
||||||
|
showExpandAllButton: true,
|
||||||
|
groupHeaderStyle: "default",
|
||||||
|
emptyMessage: "데이터가 없습니다.",
|
||||||
|
height: "auto",
|
||||||
|
maxHeight: 600,
|
||||||
|
},
|
||||||
|
defaultSize: { width: 800, height: 500 },
|
||||||
|
configPanel: TableGroupedConfigPanel,
|
||||||
|
icon: "Layers",
|
||||||
|
tags: ["테이블", "그룹화", "접기", "펼치기", "목록"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
documentation: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 타입 내보내기
|
||||||
|
export type { TableGroupedConfig } from "./types";
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ComponentConfig } from "@/types/component";
|
||||||
|
import { ColumnConfig, EntityJoinInfo } from "../v2-table-list/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 요약 설정
|
||||||
|
*/
|
||||||
|
export interface GroupSummaryConfig {
|
||||||
|
/** 합계를 계산할 컬럼 목록 */
|
||||||
|
sumColumns?: string[];
|
||||||
|
/** 개수 표시 여부 */
|
||||||
|
showCount?: boolean;
|
||||||
|
/** 평균 컬럼 목록 */
|
||||||
|
avgColumns?: string[];
|
||||||
|
/** 최대값 컬럼 목록 */
|
||||||
|
maxColumns?: string[];
|
||||||
|
/** 최소값 컬럼 목록 */
|
||||||
|
minColumns?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹화 설정
|
||||||
|
*/
|
||||||
|
export interface GroupConfig {
|
||||||
|
/** 그룹화 기준 컬럼 */
|
||||||
|
groupByColumn: string;
|
||||||
|
|
||||||
|
/** 그룹 표시 형식 (예: "{item_name} ({item_code})") */
|
||||||
|
groupLabelFormat?: string;
|
||||||
|
|
||||||
|
/** 그룹 요약 설정 */
|
||||||
|
summary?: GroupSummaryConfig;
|
||||||
|
|
||||||
|
/** 초기 펼침 상태 (기본값: true) */
|
||||||
|
defaultExpanded?: boolean;
|
||||||
|
|
||||||
|
/** 중첩 그룹 (다중 그룹화) - 향후 확장 */
|
||||||
|
nestedGroup?: GroupConfig;
|
||||||
|
|
||||||
|
/** 그룹 정렬 방식 */
|
||||||
|
sortDirection?: "asc" | "desc";
|
||||||
|
|
||||||
|
/** 그룹 정렬 컬럼 (기본: groupByColumn) */
|
||||||
|
sortColumn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹화 테이블 설정 (ComponentConfig 기반)
|
||||||
|
*/
|
||||||
|
export interface TableGroupedConfig extends ComponentConfig {
|
||||||
|
/** 테이블명 */
|
||||||
|
selectedTable?: string;
|
||||||
|
|
||||||
|
/** 커스텀 테이블 사용 여부 */
|
||||||
|
useCustomTable?: boolean;
|
||||||
|
|
||||||
|
/** 커스텀 테이블명 */
|
||||||
|
customTableName?: string;
|
||||||
|
|
||||||
|
/** 그룹화 설정 */
|
||||||
|
groupConfig: GroupConfig;
|
||||||
|
|
||||||
|
/** 컬럼 설정 */
|
||||||
|
columns?: ColumnConfig[];
|
||||||
|
|
||||||
|
/** 체크박스 표시 여부 */
|
||||||
|
showCheckbox?: boolean;
|
||||||
|
|
||||||
|
/** 체크박스 모드 */
|
||||||
|
checkboxMode?: "single" | "multi";
|
||||||
|
|
||||||
|
/** 페이지네이션 (그룹 단위) */
|
||||||
|
pagination?: {
|
||||||
|
enabled: boolean;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 기본 정렬 설정 */
|
||||||
|
defaultSort?: {
|
||||||
|
column: string;
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 읽기 전용 */
|
||||||
|
isReadOnly?: boolean;
|
||||||
|
|
||||||
|
/** 행 클릭 가능 여부 */
|
||||||
|
rowClickable?: boolean;
|
||||||
|
|
||||||
|
/** 높이 설정 */
|
||||||
|
height?: number | string;
|
||||||
|
|
||||||
|
/** 최대 높이 */
|
||||||
|
maxHeight?: number | string;
|
||||||
|
|
||||||
|
/** 전체 펼치기/접기 버튼 표시 */
|
||||||
|
showExpandAllButton?: boolean;
|
||||||
|
|
||||||
|
/** 그룹 헤더 스타일 */
|
||||||
|
groupHeaderStyle?: "default" | "compact" | "card";
|
||||||
|
|
||||||
|
/** 빈 데이터 메시지 */
|
||||||
|
emptyMessage?: string;
|
||||||
|
|
||||||
|
/** Entity 조인 컬럼 정보 */
|
||||||
|
entityJoinColumns?: Array<{
|
||||||
|
columnName: string;
|
||||||
|
entityJoinInfo: EntityJoinInfo;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/** 데이터 필터 */
|
||||||
|
dataFilter?: {
|
||||||
|
column: string;
|
||||||
|
operator: "eq" | "ne" | "gt" | "lt" | "gte" | "lte" | "like" | "in";
|
||||||
|
value: any;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
/** 연결된 필터 설정 (다른 컴포넌트와 연동) */
|
||||||
|
linkedFilters?: LinkedFilterConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결된 필터 설정
|
||||||
|
*/
|
||||||
|
export interface LinkedFilterConfig {
|
||||||
|
/** 소스 컴포넌트 ID */
|
||||||
|
sourceComponentId: string;
|
||||||
|
/** 소스 필드 */
|
||||||
|
sourceField?: string;
|
||||||
|
/** 대상 컬럼 */
|
||||||
|
targetColumn: string;
|
||||||
|
/** 활성화 여부 */
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 요약 데이터
|
||||||
|
*/
|
||||||
|
export interface GroupSummary {
|
||||||
|
/** 개수 */
|
||||||
|
count: number;
|
||||||
|
/** 합계 (컬럼별) */
|
||||||
|
sum?: Record<string, number>;
|
||||||
|
/** 평균 (컬럼별) */
|
||||||
|
avg?: Record<string, number>;
|
||||||
|
/** 최대값 (컬럼별) */
|
||||||
|
max?: Record<string, number>;
|
||||||
|
/** 최소값 (컬럼별) */
|
||||||
|
min?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 상태
|
||||||
|
*/
|
||||||
|
export interface GroupState {
|
||||||
|
/** 그룹 키 (groupByColumn 값) */
|
||||||
|
groupKey: string;
|
||||||
|
|
||||||
|
/** 그룹 표시 라벨 */
|
||||||
|
groupLabel: string;
|
||||||
|
|
||||||
|
/** 펼침 여부 */
|
||||||
|
expanded: boolean;
|
||||||
|
|
||||||
|
/** 그룹 내 데이터 */
|
||||||
|
items: any[];
|
||||||
|
|
||||||
|
/** 그룹 요약 데이터 */
|
||||||
|
summary: GroupSummary;
|
||||||
|
|
||||||
|
/** 그룹 선택 여부 */
|
||||||
|
selected?: boolean;
|
||||||
|
|
||||||
|
/** 그룹 내 선택된 아이템 ID 목록 */
|
||||||
|
selectedItemIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선택 이벤트 데이터
|
||||||
|
*/
|
||||||
|
export interface SelectionChangeEvent {
|
||||||
|
/** 선택된 그룹 키 목록 */
|
||||||
|
selectedGroups: string[];
|
||||||
|
/** 선택된 아이템 (전체) */
|
||||||
|
selectedItems: any[];
|
||||||
|
/** 모두 선택 여부 */
|
||||||
|
isAllSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 토글 이벤트
|
||||||
|
*/
|
||||||
|
export interface GroupToggleEvent {
|
||||||
|
/** 그룹 키 */
|
||||||
|
groupKey: string;
|
||||||
|
/** 펼침 상태 */
|
||||||
|
expanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 행 클릭 이벤트
|
||||||
|
*/
|
||||||
|
export interface RowClickEvent {
|
||||||
|
/** 클릭된 행 데이터 */
|
||||||
|
row: any;
|
||||||
|
/** 그룹 키 */
|
||||||
|
groupKey: string;
|
||||||
|
/** 그룹 내 인덱스 */
|
||||||
|
indexInGroup: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TableGroupedComponent Props
|
||||||
|
*/
|
||||||
|
export interface TableGroupedComponentProps {
|
||||||
|
/** 컴포넌트 설정 */
|
||||||
|
config: TableGroupedConfig;
|
||||||
|
|
||||||
|
/** 디자인 모드 여부 */
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
|
||||||
|
/** 폼 데이터 (formData 전달용) */
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
|
||||||
|
/** 선택 변경 이벤트 */
|
||||||
|
onSelectionChange?: (event: SelectionChangeEvent) => void;
|
||||||
|
|
||||||
|
/** 그룹 토글 이벤트 */
|
||||||
|
onGroupToggle?: (event: GroupToggleEvent) => void;
|
||||||
|
|
||||||
|
/** 행 클릭 이벤트 */
|
||||||
|
onRowClick?: (event: RowClickEvent) => void;
|
||||||
|
|
||||||
|
/** 외부에서 주입된 데이터 (선택) */
|
||||||
|
externalData?: any[];
|
||||||
|
|
||||||
|
/** 로딩 상태 (외부 제어) */
|
||||||
|
isLoading?: boolean;
|
||||||
|
|
||||||
|
/** 에러 상태 (외부 제어) */
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
/** 컴포넌트 ID */
|
||||||
|
componentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useGroupedData 훅 반환 타입
|
||||||
|
*/
|
||||||
|
export interface UseGroupedDataResult {
|
||||||
|
/** 그룹화된 데이터 */
|
||||||
|
groups: GroupState[];
|
||||||
|
|
||||||
|
/** 로딩 상태 */
|
||||||
|
isLoading: boolean;
|
||||||
|
|
||||||
|
/** 에러 */
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
/** 그룹 펼치기/접기 토글 */
|
||||||
|
toggleGroup: (groupKey: string) => void;
|
||||||
|
|
||||||
|
/** 전체 펼치기 */
|
||||||
|
expandAll: () => void;
|
||||||
|
|
||||||
|
/** 전체 접기 */
|
||||||
|
collapseAll: () => void;
|
||||||
|
|
||||||
|
/** 아이템 선택 토글 */
|
||||||
|
toggleItemSelection: (groupKey: string, itemId: string) => void;
|
||||||
|
|
||||||
|
/** 그룹 전체 선택 토글 */
|
||||||
|
toggleGroupSelection: (groupKey: string) => void;
|
||||||
|
|
||||||
|
/** 전체 선택 토글 */
|
||||||
|
toggleAllSelection: () => void;
|
||||||
|
|
||||||
|
/** 선택된 아이템 목록 */
|
||||||
|
selectedItems: any[];
|
||||||
|
|
||||||
|
/** 모두 선택 여부 */
|
||||||
|
isAllSelected: boolean;
|
||||||
|
|
||||||
|
/** 일부 선택 여부 */
|
||||||
|
isIndeterminate: boolean;
|
||||||
|
|
||||||
|
/** 데이터 새로고침 */
|
||||||
|
refresh: () => void;
|
||||||
|
|
||||||
|
/** 원본 데이터 */
|
||||||
|
rawData: any[];
|
||||||
|
|
||||||
|
/** 전체 데이터 개수 */
|
||||||
|
totalCount: number;
|
||||||
|
|
||||||
|
/** 그룹 개수 */
|
||||||
|
groupCount: number;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
# v2-timeline-scheduler
|
||||||
|
|
||||||
|
간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
`v2-timeline-scheduler`는 생산계획, 일정관리 등에서 사용할 수 있는 타임라인 기반의 스케줄러 컴포넌트입니다. 리소스(설비, 작업자 등)별로 스케줄을 시각화하고, 드래그/리사이즈로 일정을 조정할 수 있습니다.
|
||||||
|
|
||||||
|
## 핵심 기능
|
||||||
|
|
||||||
|
| 기능 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| 타임라인 그리드 | 일/주/월 단위 그리드 표시 |
|
||||||
|
| 스케줄 바 | 시작~종료 기간 바 렌더링 |
|
||||||
|
| 리소스 행 | 설비/작업자별 행 구분 |
|
||||||
|
| 드래그 이동 | 스케줄 바 드래그로 날짜 변경 |
|
||||||
|
| 리사이즈 | 바 양쪽 핸들로 기간 조정 |
|
||||||
|
| 줌 레벨 | 일/주/월 단위 전환 |
|
||||||
|
| 진행률 표시 | 바 내부 진행률 표시 |
|
||||||
|
| 오늘 표시선 | 현재 날짜 표시선 |
|
||||||
|
|
||||||
|
## 사용법
|
||||||
|
|
||||||
|
### 기본 사용
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { TimelineSchedulerComponent } from "@/lib/registry/components/v2-timeline-scheduler";
|
||||||
|
|
||||||
|
<TimelineSchedulerComponent
|
||||||
|
config={{
|
||||||
|
selectedTable: "production_schedule",
|
||||||
|
resourceTable: "equipment",
|
||||||
|
fieldMapping: {
|
||||||
|
id: "id",
|
||||||
|
resourceId: "equipment_id",
|
||||||
|
title: "plan_name",
|
||||||
|
startDate: "start_date",
|
||||||
|
endDate: "end_date",
|
||||||
|
status: "status",
|
||||||
|
progress: "progress",
|
||||||
|
},
|
||||||
|
resourceFieldMapping: {
|
||||||
|
id: "id",
|
||||||
|
name: "equipment_name",
|
||||||
|
},
|
||||||
|
defaultZoomLevel: "day",
|
||||||
|
editable: true,
|
||||||
|
}}
|
||||||
|
onScheduleClick={(event) => {
|
||||||
|
console.log("클릭된 스케줄:", event.schedule);
|
||||||
|
}}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
console.log("드래그 완료:", event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 설정 옵션
|
||||||
|
|
||||||
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `selectedTable` | string | - | 스케줄 데이터 테이블명 |
|
||||||
|
| `resourceTable` | string | - | 리소스 테이블명 |
|
||||||
|
| `fieldMapping` | object | - | 스케줄 필드 매핑 |
|
||||||
|
| `resourceFieldMapping` | object | - | 리소스 필드 매핑 |
|
||||||
|
| `defaultZoomLevel` | "day" \| "week" \| "month" | "day" | 기본 줌 레벨 |
|
||||||
|
| `editable` | boolean | true | 편집 가능 여부 |
|
||||||
|
| `draggable` | boolean | true | 드래그 이동 가능 |
|
||||||
|
| `resizable` | boolean | true | 리사이즈 가능 |
|
||||||
|
| `rowHeight` | number | 50 | 행 높이 (px) |
|
||||||
|
| `headerHeight` | number | 60 | 헤더 높이 (px) |
|
||||||
|
| `resourceColumnWidth` | number | 150 | 리소스 컬럼 너비 (px) |
|
||||||
|
| `showTodayLine` | boolean | true | 오늘 표시선 |
|
||||||
|
| `showProgress` | boolean | true | 진행률 표시 |
|
||||||
|
| `showToolbar` | boolean | true | 툴바 표시 |
|
||||||
|
| `height` | number \| string | 500 | 컴포넌트 높이 |
|
||||||
|
|
||||||
|
### 필드 매핑
|
||||||
|
|
||||||
|
스케줄 테이블의 컬럼을 매핑합니다:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
fieldMapping: {
|
||||||
|
id: "id", // 필수: 고유 ID
|
||||||
|
resourceId: "equipment_id", // 필수: 리소스 ID (FK)
|
||||||
|
title: "plan_name", // 필수: 표시 제목
|
||||||
|
startDate: "start_date", // 필수: 시작일
|
||||||
|
endDate: "end_date", // 필수: 종료일
|
||||||
|
status: "status", // 선택: 상태
|
||||||
|
progress: "progress", // 선택: 진행률 (0-100)
|
||||||
|
color: "color", // 선택: 바 색상
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이벤트
|
||||||
|
|
||||||
|
| 이벤트 | 파라미터 | 설명 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| `onScheduleClick` | `{ schedule, resource }` | 스케줄 클릭 |
|
||||||
|
| `onCellClick` | `{ resourceId, date }` | 빈 셀 클릭 |
|
||||||
|
| `onDragEnd` | `{ scheduleId, newStartDate, newEndDate }` | 드래그 완료 |
|
||||||
|
| `onResizeEnd` | `{ scheduleId, newStartDate, newEndDate, direction }` | 리사이즈 완료 |
|
||||||
|
| `onAddSchedule` | `(resourceId, date)` | 추가 버튼 클릭 |
|
||||||
|
|
||||||
|
### 상태별 색상
|
||||||
|
|
||||||
|
기본 상태별 색상:
|
||||||
|
|
||||||
|
| 상태 | 색상 | 의미 |
|
||||||
|
|------|------|------|
|
||||||
|
| `planned` | 파랑 (#3b82f6) | 계획됨 |
|
||||||
|
| `in_progress` | 주황 (#f59e0b) | 진행중 |
|
||||||
|
| `completed` | 초록 (#10b981) | 완료 |
|
||||||
|
| `delayed` | 빨강 (#ef4444) | 지연 |
|
||||||
|
| `cancelled` | 회색 (#6b7280) | 취소 |
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
v2-timeline-scheduler/
|
||||||
|
├── index.ts # Definition
|
||||||
|
├── types.ts # 타입 정의
|
||||||
|
├── config.ts # 기본 설정값
|
||||||
|
├── TimelineSchedulerComponent.tsx # 메인 컴포넌트
|
||||||
|
├── TimelineSchedulerConfigPanel.tsx # 설정 패널
|
||||||
|
├── TimelineSchedulerRenderer.tsx # 레지스트리 등록
|
||||||
|
├── README.md # 문서
|
||||||
|
├── components/
|
||||||
|
│ ├── index.ts
|
||||||
|
│ ├── TimelineHeader.tsx # 날짜 헤더
|
||||||
|
│ ├── ScheduleBar.tsx # 스케줄 바
|
||||||
|
│ └── ResourceRow.tsx # 리소스 행
|
||||||
|
└── hooks/
|
||||||
|
└── useTimelineData.ts # 데이터 관리 훅
|
||||||
|
```
|
||||||
|
|
||||||
|
## v2-table-list와의 차이점
|
||||||
|
|
||||||
|
| 특성 | v2-table-list | v2-timeline-scheduler |
|
||||||
|
|------|---------------|----------------------|
|
||||||
|
| 표현 방식 | 행 기반 테이블 | 시간축 기반 간트차트 |
|
||||||
|
| 데이터 구조 | 단순 목록 | 리소스 + 스케줄 (2개 테이블) |
|
||||||
|
| 편집 방식 | 폼 입력 | 드래그/리사이즈 |
|
||||||
|
| 시간 표현 | 텍스트 | 시각적 바 |
|
||||||
|
| 용도 | 일반 데이터 | 일정/계획 관리 |
|
||||||
|
|
||||||
|
## 향후 개선 사항
|
||||||
|
|
||||||
|
- [ ] 충돌 감지 및 표시
|
||||||
|
- [ ] 가상 스크롤 (대량 데이터)
|
||||||
|
- [ ] 마일스톤 표시
|
||||||
|
- [ ] 의존성 연결선
|
||||||
|
- [ ] 드래그로 새 스케줄 생성
|
||||||
|
- [ ] 컨텍스트 메뉴
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**버전**: 2.0.0
|
||||||
|
**최종 수정**: 2026-01-30
|
||||||
|
|
@ -0,0 +1,413 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Calendar,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
TimelineSchedulerComponentProps,
|
||||||
|
ScheduleItem,
|
||||||
|
ZoomLevel,
|
||||||
|
DragEvent,
|
||||||
|
ResizeEvent,
|
||||||
|
} from "./types";
|
||||||
|
import { useTimelineData } from "./hooks/useTimelineData";
|
||||||
|
import { TimelineHeader, ResourceRow } from "./components";
|
||||||
|
import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2-timeline-scheduler 메인 컴포넌트
|
||||||
|
*
|
||||||
|
* 간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트
|
||||||
|
*/
|
||||||
|
export function TimelineSchedulerComponent({
|
||||||
|
config,
|
||||||
|
isDesignMode = false,
|
||||||
|
formData,
|
||||||
|
externalSchedules,
|
||||||
|
externalResources,
|
||||||
|
isLoading: externalLoading,
|
||||||
|
error: externalError,
|
||||||
|
componentId,
|
||||||
|
onDragEnd,
|
||||||
|
onResizeEnd,
|
||||||
|
onScheduleClick,
|
||||||
|
onCellClick,
|
||||||
|
onAddSchedule,
|
||||||
|
}: TimelineSchedulerComponentProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 드래그/리사이즈 상태
|
||||||
|
const [dragState, setDragState] = useState<{
|
||||||
|
schedule: ScheduleItem;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [resizeState, setResizeState] = useState<{
|
||||||
|
schedule: ScheduleItem;
|
||||||
|
direction: "start" | "end";
|
||||||
|
startX: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// 타임라인 데이터 훅
|
||||||
|
const {
|
||||||
|
schedules,
|
||||||
|
resources,
|
||||||
|
isLoading: hookLoading,
|
||||||
|
error: hookError,
|
||||||
|
zoomLevel,
|
||||||
|
setZoomLevel,
|
||||||
|
viewStartDate,
|
||||||
|
viewEndDate,
|
||||||
|
goToPrevious,
|
||||||
|
goToNext,
|
||||||
|
goToToday,
|
||||||
|
updateSchedule,
|
||||||
|
} = useTimelineData(config, externalSchedules, externalResources);
|
||||||
|
|
||||||
|
const isLoading = externalLoading ?? hookLoading;
|
||||||
|
const error = externalError ?? hookError;
|
||||||
|
|
||||||
|
// 설정값
|
||||||
|
const rowHeight = config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!;
|
||||||
|
const headerHeight = config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!;
|
||||||
|
const resourceColumnWidth =
|
||||||
|
config.resourceColumnWidth || defaultTimelineSchedulerConfig.resourceColumnWidth!;
|
||||||
|
const cellWidthConfig = config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!;
|
||||||
|
const cellWidth = cellWidthConfig[zoomLevel] || 60;
|
||||||
|
|
||||||
|
// 리소스별 스케줄 그룹화
|
||||||
|
const schedulesByResource = useMemo(() => {
|
||||||
|
const grouped = new Map<string, ScheduleItem[]>();
|
||||||
|
|
||||||
|
resources.forEach((resource) => {
|
||||||
|
grouped.set(resource.id, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
schedules.forEach((schedule) => {
|
||||||
|
const list = grouped.get(schedule.resourceId);
|
||||||
|
if (list) {
|
||||||
|
list.push(schedule);
|
||||||
|
} else {
|
||||||
|
// 리소스가 없는 스케줄은 첫 번째 리소스에 할당
|
||||||
|
const firstResource = resources[0];
|
||||||
|
if (firstResource) {
|
||||||
|
const firstList = grouped.get(firstResource.id);
|
||||||
|
if (firstList) {
|
||||||
|
firstList.push(schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}, [schedules, resources]);
|
||||||
|
|
||||||
|
// 줌 레벨 변경
|
||||||
|
const handleZoomIn = useCallback(() => {
|
||||||
|
const levels: ZoomLevel[] = ["month", "week", "day"];
|
||||||
|
const currentIdx = levels.indexOf(zoomLevel);
|
||||||
|
if (currentIdx < levels.length - 1) {
|
||||||
|
setZoomLevel(levels[currentIdx + 1]);
|
||||||
|
}
|
||||||
|
}, [zoomLevel, setZoomLevel]);
|
||||||
|
|
||||||
|
const handleZoomOut = useCallback(() => {
|
||||||
|
const levels: ZoomLevel[] = ["month", "week", "day"];
|
||||||
|
const currentIdx = levels.indexOf(zoomLevel);
|
||||||
|
if (currentIdx > 0) {
|
||||||
|
setZoomLevel(levels[currentIdx - 1]);
|
||||||
|
}
|
||||||
|
}, [zoomLevel, setZoomLevel]);
|
||||||
|
|
||||||
|
// 스케줄 클릭 핸들러
|
||||||
|
const handleScheduleClick = useCallback(
|
||||||
|
(schedule: ScheduleItem) => {
|
||||||
|
const resource = resources.find((r) => r.id === schedule.resourceId);
|
||||||
|
if (resource && onScheduleClick) {
|
||||||
|
onScheduleClick({ schedule, resource });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[resources, onScheduleClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 빈 셀 클릭 핸들러
|
||||||
|
const handleCellClick = useCallback(
|
||||||
|
(resourceId: string, date: Date) => {
|
||||||
|
if (onCellClick) {
|
||||||
|
onCellClick({
|
||||||
|
resourceId,
|
||||||
|
date: date.toISOString().split("T")[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onCellClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 드래그 시작
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(schedule: ScheduleItem, e: React.MouseEvent) => {
|
||||||
|
setDragState({
|
||||||
|
schedule,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 드래그 종료
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
if (dragState) {
|
||||||
|
// TODO: 드래그 결과 계산 및 업데이트
|
||||||
|
setDragState(null);
|
||||||
|
}
|
||||||
|
}, [dragState]);
|
||||||
|
|
||||||
|
// 리사이즈 시작
|
||||||
|
const handleResizeStart = useCallback(
|
||||||
|
(schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => {
|
||||||
|
setResizeState({
|
||||||
|
schedule,
|
||||||
|
direction,
|
||||||
|
startX: e.clientX,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 리사이즈 종료
|
||||||
|
const handleResizeEnd = useCallback(() => {
|
||||||
|
if (resizeState) {
|
||||||
|
// TODO: 리사이즈 결과 계산 및 업데이트
|
||||||
|
setResizeState(null);
|
||||||
|
}
|
||||||
|
}, [resizeState]);
|
||||||
|
|
||||||
|
// 추가 버튼 클릭
|
||||||
|
const handleAddClick = useCallback(() => {
|
||||||
|
if (onAddSchedule && resources.length > 0) {
|
||||||
|
onAddSchedule(
|
||||||
|
resources[0].id,
|
||||||
|
new Date().toISOString().split("T")[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [onAddSchedule, resources]);
|
||||||
|
|
||||||
|
// 디자인 모드 플레이스홀더
|
||||||
|
if (isDesignMode) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full min-h-[200px] border-2 border-dashed border-muted-foreground/30 rounded-lg flex items-center justify-center bg-muted/10">
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<Calendar className="h-8 w-8 mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium">타임라인 스케줄러</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
{config.selectedTable
|
||||||
|
? `테이블: ${config.selectedTable}`
|
||||||
|
: "테이블을 선택하세요"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full flex items-center justify-center bg-muted/10 rounded-lg"
|
||||||
|
style={{ height: config.height || 500 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
<span className="text-sm">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러 상태
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full flex items-center justify-center bg-destructive/10 rounded-lg"
|
||||||
|
style={{ height: config.height || 500 }}
|
||||||
|
>
|
||||||
|
<div className="text-center text-destructive">
|
||||||
|
<p className="text-sm font-medium">오류 발생</p>
|
||||||
|
<p className="text-xs mt-1">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리소스 없음
|
||||||
|
if (resources.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full flex items-center justify-center bg-muted/10 rounded-lg"
|
||||||
|
style={{ height: config.height || 500 }}
|
||||||
|
>
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<Calendar className="h-8 w-8 mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium">리소스가 없습니다</p>
|
||||||
|
<p className="text-xs mt-1">리소스 테이블을 설정하세요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full border rounded-lg overflow-hidden bg-background"
|
||||||
|
style={{
|
||||||
|
height: config.height || 500,
|
||||||
|
maxHeight: config.maxHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 툴바 */}
|
||||||
|
{config.showToolbar !== false && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
|
||||||
|
{/* 네비게이션 */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{config.showNavigation !== false && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToPrevious}
|
||||||
|
className="h-7 px-2"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToToday}
|
||||||
|
className="h-7 px-2"
|
||||||
|
>
|
||||||
|
오늘
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToNext}
|
||||||
|
className="h-7 px-2"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 현재 날짜 범위 표시 */}
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">
|
||||||
|
{viewStartDate.getFullYear()}년 {viewStartDate.getMonth() + 1}월{" "}
|
||||||
|
{viewStartDate.getDate()}일 ~{" "}
|
||||||
|
{viewEndDate.getMonth() + 1}월 {viewEndDate.getDate()}일
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽 컨트롤 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 줌 컨트롤 */}
|
||||||
|
{config.showZoomControls !== false && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
disabled={zoomLevel === "month"}
|
||||||
|
className="h-7 px-2"
|
||||||
|
>
|
||||||
|
<ZoomOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground min-w-[24px] text-center">
|
||||||
|
{zoomLevelOptions.find((o) => o.value === zoomLevel)?.label}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
disabled={zoomLevel === "day"}
|
||||||
|
className="h-7 px-2"
|
||||||
|
>
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 추가 버튼 */}
|
||||||
|
{config.showAddButton !== false && config.editable && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddClick}
|
||||||
|
className="h-7"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 타임라인 본문 */}
|
||||||
|
<div
|
||||||
|
className="overflow-auto"
|
||||||
|
style={{
|
||||||
|
height: config.showToolbar !== false
|
||||||
|
? `calc(100% - 48px)`
|
||||||
|
: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="min-w-max">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<TimelineHeader
|
||||||
|
startDate={viewStartDate}
|
||||||
|
endDate={viewEndDate}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
cellWidth={cellWidth}
|
||||||
|
headerHeight={headerHeight}
|
||||||
|
resourceColumnWidth={resourceColumnWidth}
|
||||||
|
showTodayLine={config.showTodayLine}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 리소스 행들 */}
|
||||||
|
<div>
|
||||||
|
{resources.map((resource) => (
|
||||||
|
<ResourceRow
|
||||||
|
key={resource.id}
|
||||||
|
resource={resource}
|
||||||
|
schedules={schedulesByResource.get(resource.id) || []}
|
||||||
|
startDate={viewStartDate}
|
||||||
|
endDate={viewEndDate}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
cellWidth={cellWidth}
|
||||||
|
resourceColumnWidth={resourceColumnWidth}
|
||||||
|
config={config}
|
||||||
|
onScheduleClick={handleScheduleClick}
|
||||||
|
onCellClick={handleCellClick}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onResizeStart={handleResizeStart}
|
||||||
|
onResizeEnd={handleResizeEnd}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,629 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { TimelineSchedulerConfig } from "./types";
|
||||||
|
import { zoomLevelOptions, statusOptions } from "./config";
|
||||||
|
|
||||||
|
interface TimelineSchedulerConfigPanelProps {
|
||||||
|
config: TimelineSchedulerConfig;
|
||||||
|
onChange: (config: Partial<TimelineSchedulerConfig>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
columnName: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelineSchedulerConfigPanel({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}: TimelineSchedulerConfigPanelProps) {
|
||||||
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
|
const [tableColumns, setTableColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [resourceColumns, setResourceColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tableSelectOpen, setTableSelectOpen] = useState(false);
|
||||||
|
const [resourceTableSelectOpen, setResourceTableSelectOpen] = useState(false);
|
||||||
|
|
||||||
|
// 테이블 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTables = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const tableList = await tableTypeApi.getTables();
|
||||||
|
if (Array.isArray(tableList)) {
|
||||||
|
setTables(
|
||||||
|
tableList.map((t: any) => ({
|
||||||
|
tableName: t.table_name || t.tableName,
|
||||||
|
displayName: t.display_name || t.displayName || t.table_name || t.tableName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("테이블 목록 로드 오류:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadTables();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 스케줄 테이블 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadColumns = async () => {
|
||||||
|
if (!config.selectedTable) {
|
||||||
|
setTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const columns = await tableTypeApi.getColumns(config.selectedTable);
|
||||||
|
if (Array.isArray(columns)) {
|
||||||
|
setTableColumns(
|
||||||
|
columns.map((col: any) => ({
|
||||||
|
columnName: col.column_name || col.columnName,
|
||||||
|
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("컬럼 로드 오류:", err);
|
||||||
|
setTableColumns([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadColumns();
|
||||||
|
}, [config.selectedTable]);
|
||||||
|
|
||||||
|
// 리소스 테이블 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadResourceColumns = async () => {
|
||||||
|
if (!config.resourceTable) {
|
||||||
|
setResourceColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const columns = await tableTypeApi.getColumns(config.resourceTable);
|
||||||
|
if (Array.isArray(columns)) {
|
||||||
|
setResourceColumns(
|
||||||
|
columns.map((col: any) => ({
|
||||||
|
columnName: col.column_name || col.columnName,
|
||||||
|
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("리소스 컬럼 로드 오류:", err);
|
||||||
|
setResourceColumns([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadResourceColumns();
|
||||||
|
}, [config.resourceTable]);
|
||||||
|
|
||||||
|
// 설정 업데이트 헬퍼
|
||||||
|
const updateConfig = (updates: Partial<TimelineSchedulerConfig>) => {
|
||||||
|
onChange({ ...config, ...updates });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 매핑 업데이트
|
||||||
|
const updateFieldMapping = (field: string, value: string) => {
|
||||||
|
updateConfig({
|
||||||
|
fieldMapping: {
|
||||||
|
...config.fieldMapping,
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 리소스 필드 매핑 업데이트
|
||||||
|
const updateResourceFieldMapping = (field: string, value: string) => {
|
||||||
|
updateConfig({
|
||||||
|
resourceFieldMapping: {
|
||||||
|
...config.resourceFieldMapping,
|
||||||
|
id: config.resourceFieldMapping?.id || "id",
|
||||||
|
name: config.resourceFieldMapping?.name || "name",
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<Accordion type="multiple" defaultValue={["table", "mapping", "display"]}>
|
||||||
|
{/* 테이블 설정 */}
|
||||||
|
<AccordionItem value="table">
|
||||||
|
<AccordionTrigger className="text-sm font-medium">
|
||||||
|
테이블 설정
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3 pt-2">
|
||||||
|
{/* 스케줄 테이블 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">스케줄 테이블</Label>
|
||||||
|
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableSelectOpen}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
로딩 중...
|
||||||
|
</span>
|
||||||
|
) : config.selectedTable ? (
|
||||||
|
tables.find((t) => t.tableName === config.selectedTable)
|
||||||
|
?.displayName || config.selectedTable
|
||||||
|
) : (
|
||||||
|
"테이블 선택..."
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command
|
||||||
|
filter={(value, search) => {
|
||||||
|
const lowerSearch = search.toLowerCase();
|
||||||
|
if (value.toLowerCase().includes(lowerSearch)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs">
|
||||||
|
테이블을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.displayName} ${table.tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
updateConfig({ selectedTable: table.tableName });
|
||||||
|
setTableSelectOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.selectedTable === table.tableName
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{table.displayName}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{table.tableName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리소스 테이블 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">리소스 테이블 (설비/작업자)</Label>
|
||||||
|
<Popover
|
||||||
|
open={resourceTableSelectOpen}
|
||||||
|
onOpenChange={setResourceTableSelectOpen}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={resourceTableSelectOpen}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{config.resourceTable ? (
|
||||||
|
tables.find((t) => t.tableName === config.resourceTable)
|
||||||
|
?.displayName || config.resourceTable
|
||||||
|
) : (
|
||||||
|
"리소스 테이블 선택..."
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command
|
||||||
|
filter={(value, search) => {
|
||||||
|
const lowerSearch = search.toLowerCase();
|
||||||
|
if (value.toLowerCase().includes(lowerSearch)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs">
|
||||||
|
테이블을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.displayName} ${table.tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
updateConfig({ resourceTable: table.tableName });
|
||||||
|
setResourceTableSelectOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.resourceTable === table.tableName
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{table.displayName}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{table.tableName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* 필드 매핑 */}
|
||||||
|
<AccordionItem value="mapping">
|
||||||
|
<AccordionTrigger className="text-sm font-medium">
|
||||||
|
필드 매핑
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3 pt-2">
|
||||||
|
{/* 스케줄 필드 매핑 */}
|
||||||
|
{config.selectedTable && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">스케줄 필드</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{/* ID 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">ID</Label>
|
||||||
|
<Select
|
||||||
|
value={config.fieldMapping?.id || ""}
|
||||||
|
onValueChange={(v) => updateFieldMapping("id", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리소스 ID 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">리소스 ID</Label>
|
||||||
|
<Select
|
||||||
|
value={config.fieldMapping?.resourceId || ""}
|
||||||
|
onValueChange={(v) => updateFieldMapping("resourceId", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제목 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">제목</Label>
|
||||||
|
<Select
|
||||||
|
value={config.fieldMapping?.title || ""}
|
||||||
|
onValueChange={(v) => updateFieldMapping("title", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시작일 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">시작일</Label>
|
||||||
|
<Select
|
||||||
|
value={config.fieldMapping?.startDate || ""}
|
||||||
|
onValueChange={(v) => updateFieldMapping("startDate", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 종료일 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">종료일</Label>
|
||||||
|
<Select
|
||||||
|
value={config.fieldMapping?.endDate || ""}
|
||||||
|
onValueChange={(v) => updateFieldMapping("endDate", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">상태 (선택)</Label>
|
||||||
|
<Select
|
||||||
|
value={config.fieldMapping?.status || "__none__"}
|
||||||
|
onValueChange={(v) => updateFieldMapping("status", v === "__none__" ? "" : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">없음</SelectItem>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 리소스 필드 매핑 */}
|
||||||
|
{config.resourceTable && (
|
||||||
|
<div className="space-y-2 mt-3">
|
||||||
|
<Label className="text-xs font-medium">리소스 필드</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{/* ID 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">ID</Label>
|
||||||
|
<Select
|
||||||
|
value={config.resourceFieldMapping?.id || ""}
|
||||||
|
onValueChange={(v) => updateResourceFieldMapping("id", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{resourceColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이름 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">이름</Label>
|
||||||
|
<Select
|
||||||
|
value={config.resourceFieldMapping?.name || ""}
|
||||||
|
onValueChange={(v) => updateResourceFieldMapping("name", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{resourceColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* 표시 설정 */}
|
||||||
|
<AccordionItem value="display">
|
||||||
|
<AccordionTrigger className="text-sm font-medium">
|
||||||
|
표시 설정
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3 pt-2">
|
||||||
|
{/* 기본 줌 레벨 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">기본 줌 레벨</Label>
|
||||||
|
<Select
|
||||||
|
value={config.defaultZoomLevel || "day"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateConfig({ defaultZoomLevel: v as any })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{zoomLevelOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 높이 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">높이 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.height || 500}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ height: parseInt(e.target.value) || 500 })
|
||||||
|
}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 행 높이 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">행 높이 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.rowHeight || 50}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ rowHeight: parseInt(e.target.value) || 50 })
|
||||||
|
}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 토글 스위치들 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">편집 가능</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.editable ?? true}
|
||||||
|
onCheckedChange={(v) => updateConfig({ editable: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">드래그 이동</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.draggable ?? true}
|
||||||
|
onCheckedChange={(v) => updateConfig({ draggable: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">리사이즈</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.resizable ?? true}
|
||||||
|
onCheckedChange={(v) => updateConfig({ resizable: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">오늘 표시선</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.showTodayLine ?? true}
|
||||||
|
onCheckedChange={(v) => updateConfig({ showTodayLine: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">진행률 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.showProgress ?? true}
|
||||||
|
onCheckedChange={(v) => updateConfig({ showProgress: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">툴바 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.showToolbar ?? true}
|
||||||
|
onCheckedChange={(v) => updateConfig({ showToolbar: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimelineSchedulerConfigPanel;
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
|
import { V2TimelineSchedulerDefinition } from "./index";
|
||||||
|
import { TimelineSchedulerComponent } from "./TimelineSchedulerComponent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TimelineScheduler 렌더러
|
||||||
|
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||||
|
*/
|
||||||
|
export class TimelineSchedulerRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = V2TimelineSchedulerDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<TimelineSchedulerComponent
|
||||||
|
{...this.props}
|
||||||
|
config={this.props.component?.componentConfig || {}}
|
||||||
|
isDesignMode={this.props.isDesignMode}
|
||||||
|
formData={this.props.formData}
|
||||||
|
componentId={this.props.component?.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설정 변경 핸들러
|
||||||
|
protected handleConfigChange = (config: any) => {
|
||||||
|
console.log("📥 TimelineSchedulerRenderer에서 설정 변경 받음:", config);
|
||||||
|
|
||||||
|
// 상위 컴포넌트의 onConfigChange 호출 (화면 설계자에게 알림)
|
||||||
|
if (this.props.onConfigChange) {
|
||||||
|
this.props.onConfigChange(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateComponent({ config });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 값 변경 처리
|
||||||
|
protected handleValueChange = (value: any) => {
|
||||||
|
this.updateComponent({ value });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 등록 실행
|
||||||
|
TimelineSchedulerRenderer.registerSelf();
|
||||||
|
|
||||||
|
// 강제 등록 (디버깅용)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
TimelineSchedulerRenderer.registerSelf();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ TimelineScheduler 강제 등록 실패:", error);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Resource, ScheduleItem, ZoomLevel, TimelineSchedulerConfig } from "../types";
|
||||||
|
import { ScheduleBar } from "./ScheduleBar";
|
||||||
|
|
||||||
|
interface ResourceRowProps {
|
||||||
|
/** 리소스 */
|
||||||
|
resource: Resource;
|
||||||
|
/** 해당 리소스의 스케줄 목록 */
|
||||||
|
schedules: ScheduleItem[];
|
||||||
|
/** 시작 날짜 */
|
||||||
|
startDate: Date;
|
||||||
|
/** 종료 날짜 */
|
||||||
|
endDate: Date;
|
||||||
|
/** 줌 레벨 */
|
||||||
|
zoomLevel: ZoomLevel;
|
||||||
|
/** 행 높이 */
|
||||||
|
rowHeight: number;
|
||||||
|
/** 셀 너비 */
|
||||||
|
cellWidth: number;
|
||||||
|
/** 리소스 컬럼 너비 */
|
||||||
|
resourceColumnWidth: number;
|
||||||
|
/** 설정 */
|
||||||
|
config: TimelineSchedulerConfig;
|
||||||
|
/** 스케줄 클릭 */
|
||||||
|
onScheduleClick?: (schedule: ScheduleItem) => void;
|
||||||
|
/** 빈 셀 클릭 */
|
||||||
|
onCellClick?: (resourceId: string, date: Date) => void;
|
||||||
|
/** 드래그 시작 */
|
||||||
|
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void;
|
||||||
|
/** 드래그 종료 */
|
||||||
|
onDragEnd?: () => void;
|
||||||
|
/** 리사이즈 시작 */
|
||||||
|
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void;
|
||||||
|
/** 리사이즈 종료 */
|
||||||
|
onResizeEnd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 차이 계산 (일수)
|
||||||
|
*/
|
||||||
|
const getDaysDiff = (start: Date, end: Date): number => {
|
||||||
|
const startTime = new Date(start).setHours(0, 0, 0, 0);
|
||||||
|
const endTime = new Date(end).setHours(0, 0, 0, 0);
|
||||||
|
return Math.round((endTime - startTime) / (1000 * 60 * 60 * 24));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 범위 내의 셀 개수 계산
|
||||||
|
*/
|
||||||
|
const getCellCount = (startDate: Date, endDate: Date): number => {
|
||||||
|
return getDaysDiff(startDate, endDate) + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResourceRow({
|
||||||
|
resource,
|
||||||
|
schedules,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
zoomLevel,
|
||||||
|
rowHeight,
|
||||||
|
cellWidth,
|
||||||
|
resourceColumnWidth,
|
||||||
|
config,
|
||||||
|
onScheduleClick,
|
||||||
|
onCellClick,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onResizeStart,
|
||||||
|
onResizeEnd,
|
||||||
|
}: ResourceRowProps) {
|
||||||
|
// 총 셀 개수
|
||||||
|
const totalCells = useMemo(() => getCellCount(startDate, endDate), [startDate, endDate]);
|
||||||
|
|
||||||
|
// 총 그리드 너비
|
||||||
|
const gridWidth = totalCells * cellWidth;
|
||||||
|
|
||||||
|
// 오늘 날짜
|
||||||
|
const today = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 스케줄 바 위치 계산
|
||||||
|
const schedulePositions = useMemo(() => {
|
||||||
|
return schedules.map((schedule) => {
|
||||||
|
const scheduleStart = new Date(schedule.startDate);
|
||||||
|
const scheduleEnd = new Date(schedule.endDate);
|
||||||
|
scheduleStart.setHours(0, 0, 0, 0);
|
||||||
|
scheduleEnd.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// 시작 위치 계산
|
||||||
|
const startOffset = getDaysDiff(startDate, scheduleStart);
|
||||||
|
const left = Math.max(0, startOffset * cellWidth);
|
||||||
|
|
||||||
|
// 너비 계산
|
||||||
|
const durationDays = getDaysDiff(scheduleStart, scheduleEnd) + 1;
|
||||||
|
const visibleStartOffset = Math.max(0, startOffset);
|
||||||
|
const visibleEndOffset = Math.min(
|
||||||
|
totalCells,
|
||||||
|
startOffset + durationDays
|
||||||
|
);
|
||||||
|
const width = Math.max(cellWidth, (visibleEndOffset - visibleStartOffset) * cellWidth);
|
||||||
|
|
||||||
|
return {
|
||||||
|
schedule,
|
||||||
|
position: {
|
||||||
|
left: resourceColumnWidth + left,
|
||||||
|
top: 0,
|
||||||
|
width,
|
||||||
|
height: rowHeight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [schedules, startDate, cellWidth, resourceColumnWidth, rowHeight, totalCells]);
|
||||||
|
|
||||||
|
// 그리드 셀 클릭 핸들러
|
||||||
|
const handleGridClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!onCellClick) return;
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const cellIndex = Math.floor(x / cellWidth);
|
||||||
|
|
||||||
|
const clickedDate = new Date(startDate);
|
||||||
|
clickedDate.setDate(clickedDate.getDate() + cellIndex);
|
||||||
|
|
||||||
|
onCellClick(resource.id, clickedDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex border-b hover:bg-muted/20"
|
||||||
|
style={{ height: rowHeight }}
|
||||||
|
>
|
||||||
|
{/* 리소스 컬럼 */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 border-r bg-muted/30 flex items-center px-3 sticky left-0 z-10"
|
||||||
|
style={{ width: resourceColumnWidth }}
|
||||||
|
>
|
||||||
|
<div className="truncate">
|
||||||
|
<div className="font-medium text-sm truncate">{resource.name}</div>
|
||||||
|
{resource.group && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{resource.group}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타임라인 그리드 */}
|
||||||
|
<div
|
||||||
|
className="relative flex-1"
|
||||||
|
style={{ width: gridWidth }}
|
||||||
|
onClick={handleGridClick}
|
||||||
|
>
|
||||||
|
{/* 배경 그리드 */}
|
||||||
|
<div className="absolute inset-0 flex">
|
||||||
|
{Array.from({ length: totalCells }).map((_, idx) => {
|
||||||
|
const cellDate = new Date(startDate);
|
||||||
|
cellDate.setDate(cellDate.getDate() + idx);
|
||||||
|
const isWeekend = cellDate.getDay() === 0 || cellDate.getDay() === 6;
|
||||||
|
const isToday = cellDate.getTime() === today.getTime();
|
||||||
|
const isMonthStart = cellDate.getDate() === 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"border-r h-full",
|
||||||
|
isWeekend && "bg-muted/20",
|
||||||
|
isToday && "bg-primary/5",
|
||||||
|
isMonthStart && "border-l-2 border-l-primary/20"
|
||||||
|
)}
|
||||||
|
style={{ width: cellWidth }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 스케줄 바들 */}
|
||||||
|
{schedulePositions.map(({ schedule, position }) => (
|
||||||
|
<ScheduleBar
|
||||||
|
key={schedule.id}
|
||||||
|
schedule={schedule}
|
||||||
|
position={{
|
||||||
|
...position,
|
||||||
|
left: position.left - resourceColumnWidth, // 상대 위치
|
||||||
|
}}
|
||||||
|
config={config}
|
||||||
|
draggable={config.draggable}
|
||||||
|
resizable={config.resizable}
|
||||||
|
onClick={() => onScheduleClick?.(schedule)}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onResizeStart={onResizeStart}
|
||||||
|
onResizeEnd={onResizeEnd}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ScheduleItem, ScheduleBarPosition, TimelineSchedulerConfig } from "../types";
|
||||||
|
import { statusOptions } from "../config";
|
||||||
|
|
||||||
|
interface ScheduleBarProps {
|
||||||
|
/** 스케줄 항목 */
|
||||||
|
schedule: ScheduleItem;
|
||||||
|
/** 위치 정보 */
|
||||||
|
position: ScheduleBarPosition;
|
||||||
|
/** 설정 */
|
||||||
|
config: TimelineSchedulerConfig;
|
||||||
|
/** 드래그 가능 여부 */
|
||||||
|
draggable?: boolean;
|
||||||
|
/** 리사이즈 가능 여부 */
|
||||||
|
resizable?: boolean;
|
||||||
|
/** 클릭 이벤트 */
|
||||||
|
onClick?: (schedule: ScheduleItem) => void;
|
||||||
|
/** 드래그 시작 */
|
||||||
|
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void;
|
||||||
|
/** 드래그 중 */
|
||||||
|
onDrag?: (deltaX: number, deltaY: number) => void;
|
||||||
|
/** 드래그 종료 */
|
||||||
|
onDragEnd?: () => void;
|
||||||
|
/** 리사이즈 시작 */
|
||||||
|
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void;
|
||||||
|
/** 리사이즈 중 */
|
||||||
|
onResize?: (deltaX: number, direction: "start" | "end") => void;
|
||||||
|
/** 리사이즈 종료 */
|
||||||
|
onResizeEnd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleBar({
|
||||||
|
schedule,
|
||||||
|
position,
|
||||||
|
config,
|
||||||
|
draggable = true,
|
||||||
|
resizable = true,
|
||||||
|
onClick,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onResizeStart,
|
||||||
|
onResizeEnd,
|
||||||
|
}: ScheduleBarProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const barRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 상태에 따른 색상
|
||||||
|
const statusColor = schedule.color ||
|
||||||
|
config.statusColors?.[schedule.status] ||
|
||||||
|
statusOptions.find((s) => s.value === schedule.status)?.color ||
|
||||||
|
"#3b82f6";
|
||||||
|
|
||||||
|
// 진행률 바 너비
|
||||||
|
const progressWidth = config.showProgress && schedule.progress !== undefined
|
||||||
|
? `${schedule.progress}%`
|
||||||
|
: "0%";
|
||||||
|
|
||||||
|
// 드래그 시작 핸들러
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!draggable || isResizing) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
onDragStart?.(schedule, e);
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
// 드래그 중 로직은 부모에서 처리
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
onDragEnd?.();
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
},
|
||||||
|
[draggable, isResizing, schedule, onDragStart, onDragEnd]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 리사이즈 시작 핸들러
|
||||||
|
const handleResizeStart = useCallback(
|
||||||
|
(direction: "start" | "end", e: React.MouseEvent) => {
|
||||||
|
if (!resizable) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsResizing(true);
|
||||||
|
onResizeStart?.(schedule, direction, e);
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
// 리사이즈 중 로직은 부모에서 처리
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsResizing(false);
|
||||||
|
onResizeEnd?.();
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
},
|
||||||
|
[resizable, schedule, onResizeStart, onResizeEnd]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 클릭 핸들러
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (isDragging || isResizing) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.(schedule);
|
||||||
|
},
|
||||||
|
[isDragging, isResizing, onClick, schedule]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={barRef}
|
||||||
|
className={cn(
|
||||||
|
"absolute rounded-md shadow-sm cursor-pointer transition-shadow",
|
||||||
|
"hover:shadow-md hover:z-10",
|
||||||
|
isDragging && "opacity-70 shadow-lg z-20",
|
||||||
|
isResizing && "z-20",
|
||||||
|
draggable && "cursor-grab",
|
||||||
|
isDragging && "cursor-grabbing"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: position.left,
|
||||||
|
top: position.top + 4,
|
||||||
|
width: position.width,
|
||||||
|
height: position.height - 8,
|
||||||
|
backgroundColor: statusColor,
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
>
|
||||||
|
{/* 진행률 바 */}
|
||||||
|
{config.showProgress && schedule.progress !== undefined && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 rounded-l-md opacity-30 bg-white"
|
||||||
|
style={{ width: progressWidth }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<div className="relative z-10 px-2 py-1 text-xs text-white truncate font-medium">
|
||||||
|
{schedule.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 진행률 텍스트 */}
|
||||||
|
{config.showProgress && schedule.progress !== undefined && (
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-white/80 font-medium">
|
||||||
|
{schedule.progress}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 리사이즈 핸들 - 왼쪽 */}
|
||||||
|
{resizable && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-white/20 rounded-l-md"
|
||||||
|
onMouseDown={(e) => handleResizeStart("start", e)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 리사이즈 핸들 - 오른쪽 */}
|
||||||
|
{resizable && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-white/20 rounded-r-md"
|
||||||
|
onMouseDown={(e) => handleResizeStart("end", e)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { DateCell, ZoomLevel } from "../types";
|
||||||
|
import { dayLabels, monthLabels } from "../config";
|
||||||
|
|
||||||
|
interface TimelineHeaderProps {
|
||||||
|
/** 시작 날짜 */
|
||||||
|
startDate: Date;
|
||||||
|
/** 종료 날짜 */
|
||||||
|
endDate: Date;
|
||||||
|
/** 줌 레벨 */
|
||||||
|
zoomLevel: ZoomLevel;
|
||||||
|
/** 셀 너비 */
|
||||||
|
cellWidth: number;
|
||||||
|
/** 헤더 높이 */
|
||||||
|
headerHeight: number;
|
||||||
|
/** 리소스 컬럼 너비 */
|
||||||
|
resourceColumnWidth: number;
|
||||||
|
/** 오늘 표시선 */
|
||||||
|
showTodayLine?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 범위 내의 모든 날짜 셀 생성
|
||||||
|
*/
|
||||||
|
const generateDateCells = (
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
zoomLevel: ZoomLevel
|
||||||
|
): DateCell[] => {
|
||||||
|
const cells: DateCell[] = [];
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const current = new Date(startDate);
|
||||||
|
current.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
while (current <= endDate) {
|
||||||
|
const date = new Date(current);
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
const isToday = date.getTime() === today.getTime();
|
||||||
|
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||||
|
const isMonthStart = date.getDate() === 1;
|
||||||
|
|
||||||
|
let label = "";
|
||||||
|
if (zoomLevel === "day") {
|
||||||
|
label = `${date.getDate()}(${dayLabels[dayOfWeek]})`;
|
||||||
|
} else if (zoomLevel === "week") {
|
||||||
|
// 주간: 월요일 기준 주 시작
|
||||||
|
if (dayOfWeek === 1 || cells.length === 0) {
|
||||||
|
label = `${date.getMonth() + 1}/${date.getDate()}`;
|
||||||
|
}
|
||||||
|
} else if (zoomLevel === "month") {
|
||||||
|
// 월간: 월 시작일만 표시
|
||||||
|
if (isMonthStart || cells.length === 0) {
|
||||||
|
label = monthLabels[date.getMonth()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cells.push({
|
||||||
|
date,
|
||||||
|
label,
|
||||||
|
isToday,
|
||||||
|
isWeekend,
|
||||||
|
isMonthStart,
|
||||||
|
});
|
||||||
|
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월 헤더 그룹 생성 (상단 행)
|
||||||
|
*/
|
||||||
|
const generateMonthGroups = (
|
||||||
|
cells: DateCell[]
|
||||||
|
): { month: string; year: number; count: number }[] => {
|
||||||
|
const groups: { month: string; year: number; count: number }[] = [];
|
||||||
|
|
||||||
|
cells.forEach((cell) => {
|
||||||
|
const month = monthLabels[cell.date.getMonth()];
|
||||||
|
const year = cell.date.getFullYear();
|
||||||
|
|
||||||
|
if (
|
||||||
|
groups.length === 0 ||
|
||||||
|
groups[groups.length - 1].month !== month ||
|
||||||
|
groups[groups.length - 1].year !== year
|
||||||
|
) {
|
||||||
|
groups.push({ month, year, count: 1 });
|
||||||
|
} else {
|
||||||
|
groups[groups.length - 1].count++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TimelineHeader({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
zoomLevel,
|
||||||
|
cellWidth,
|
||||||
|
headerHeight,
|
||||||
|
resourceColumnWidth,
|
||||||
|
showTodayLine = true,
|
||||||
|
}: TimelineHeaderProps) {
|
||||||
|
// 날짜 셀 생성
|
||||||
|
const dateCells = useMemo(
|
||||||
|
() => generateDateCells(startDate, endDate, zoomLevel),
|
||||||
|
[startDate, endDate, zoomLevel]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 월 그룹 생성
|
||||||
|
const monthGroups = useMemo(() => generateMonthGroups(dateCells), [dateCells]);
|
||||||
|
|
||||||
|
// 오늘 위치 계산
|
||||||
|
const todayPosition = useMemo(() => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const todayIndex = dateCells.findIndex(
|
||||||
|
(cell) => cell.date.getTime() === today.getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (todayIndex === -1) return null;
|
||||||
|
|
||||||
|
return resourceColumnWidth + todayIndex * cellWidth + cellWidth / 2;
|
||||||
|
}, [dateCells, cellWidth, resourceColumnWidth]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sticky top-0 z-20 border-b bg-background"
|
||||||
|
style={{ height: headerHeight }}
|
||||||
|
>
|
||||||
|
{/* 상단 행: 월/년도 */}
|
||||||
|
<div className="flex" style={{ height: headerHeight / 2 }}>
|
||||||
|
{/* 리소스 컬럼 헤더 */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 border-r bg-muted/50 flex items-center justify-center font-medium text-sm"
|
||||||
|
style={{ width: resourceColumnWidth }}
|
||||||
|
>
|
||||||
|
리소스
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 월 그룹 */}
|
||||||
|
{monthGroups.map((group, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${group.year}-${group.month}-${idx}`}
|
||||||
|
className="border-r flex items-center justify-center text-xs font-medium text-muted-foreground"
|
||||||
|
style={{ width: group.count * cellWidth }}
|
||||||
|
>
|
||||||
|
{group.year}년 {group.month}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 행: 일자 */}
|
||||||
|
<div className="flex" style={{ height: headerHeight / 2 }}>
|
||||||
|
{/* 리소스 컬럼 (빈칸) */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 border-r bg-muted/50"
|
||||||
|
style={{ width: resourceColumnWidth }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 날짜 셀 */}
|
||||||
|
{dateCells.map((cell, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"border-r flex items-center justify-center text-xs",
|
||||||
|
cell.isToday && "bg-primary/10 font-bold text-primary",
|
||||||
|
cell.isWeekend && !cell.isToday && "bg-muted/30 text-muted-foreground",
|
||||||
|
cell.isMonthStart && "border-l-2 border-l-primary/30"
|
||||||
|
)}
|
||||||
|
style={{ width: cellWidth }}
|
||||||
|
>
|
||||||
|
{cell.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오늘 표시선 */}
|
||||||
|
{showTodayLine && todayPosition !== null && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-0.5 bg-primary z-30 pointer-events-none"
|
||||||
|
style={{ left: todayPosition }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { TimelineHeader } from "./TimelineHeader";
|
||||||
|
export { ScheduleBar } from "./ScheduleBar";
|
||||||
|
export { ResourceRow } from "./ResourceRow";
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TimelineSchedulerConfig, ZoomLevel } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 타임라인 스케줄러 설정
|
||||||
|
*/
|
||||||
|
export const defaultTimelineSchedulerConfig: Partial<TimelineSchedulerConfig> = {
|
||||||
|
defaultZoomLevel: "day",
|
||||||
|
editable: true,
|
||||||
|
draggable: true,
|
||||||
|
resizable: true,
|
||||||
|
rowHeight: 50,
|
||||||
|
headerHeight: 60,
|
||||||
|
resourceColumnWidth: 150,
|
||||||
|
cellWidth: {
|
||||||
|
day: 60,
|
||||||
|
week: 120,
|
||||||
|
month: 40,
|
||||||
|
},
|
||||||
|
showConflicts: true,
|
||||||
|
showProgress: true,
|
||||||
|
showTodayLine: true,
|
||||||
|
showToolbar: true,
|
||||||
|
showZoomControls: true,
|
||||||
|
showNavigation: true,
|
||||||
|
showAddButton: true,
|
||||||
|
height: 500,
|
||||||
|
statusColors: {
|
||||||
|
planned: "#3b82f6", // blue-500
|
||||||
|
in_progress: "#f59e0b", // amber-500
|
||||||
|
completed: "#10b981", // emerald-500
|
||||||
|
delayed: "#ef4444", // red-500
|
||||||
|
cancelled: "#6b7280", // gray-500
|
||||||
|
},
|
||||||
|
fieldMapping: {
|
||||||
|
id: "id",
|
||||||
|
resourceId: "resource_id",
|
||||||
|
title: "title",
|
||||||
|
startDate: "start_date",
|
||||||
|
endDate: "end_date",
|
||||||
|
status: "status",
|
||||||
|
progress: "progress",
|
||||||
|
},
|
||||||
|
resourceFieldMapping: {
|
||||||
|
id: "id",
|
||||||
|
name: "name",
|
||||||
|
group: "group",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 줌 레벨 옵션
|
||||||
|
*/
|
||||||
|
export const zoomLevelOptions: { value: ZoomLevel; label: string }[] = [
|
||||||
|
{ value: "day", label: "일" },
|
||||||
|
{ value: "week", label: "주" },
|
||||||
|
{ value: "month", label: "월" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 옵션
|
||||||
|
*/
|
||||||
|
export const statusOptions = [
|
||||||
|
{ value: "planned", label: "계획됨", color: "#3b82f6" },
|
||||||
|
{ value: "in_progress", label: "진행중", color: "#f59e0b" },
|
||||||
|
{ value: "completed", label: "완료", color: "#10b981" },
|
||||||
|
{ value: "delayed", label: "지연", color: "#ef4444" },
|
||||||
|
{ value: "cancelled", label: "취소", color: "#6b7280" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 줌 레벨별 표시 일수
|
||||||
|
*/
|
||||||
|
export const zoomLevelDays: Record<ZoomLevel, number> = {
|
||||||
|
day: 14, // 2주
|
||||||
|
week: 56, // 8주
|
||||||
|
month: 90, // 3개월
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요일 라벨 (한글)
|
||||||
|
*/
|
||||||
|
export const dayLabels = ["일", "월", "화", "수", "목", "금", "토"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월 라벨 (한글)
|
||||||
|
*/
|
||||||
|
export const monthLabels = [
|
||||||
|
"1월",
|
||||||
|
"2월",
|
||||||
|
"3월",
|
||||||
|
"4월",
|
||||||
|
"5월",
|
||||||
|
"6월",
|
||||||
|
"7월",
|
||||||
|
"8월",
|
||||||
|
"9월",
|
||||||
|
"10월",
|
||||||
|
"11월",
|
||||||
|
"12월",
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
TimelineSchedulerConfig,
|
||||||
|
ScheduleItem,
|
||||||
|
Resource,
|
||||||
|
ZoomLevel,
|
||||||
|
UseTimelineDataResult,
|
||||||
|
} from "../types";
|
||||||
|
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜를 ISO 문자열로 변환 (시간 제외)
|
||||||
|
*/
|
||||||
|
const toDateString = (date: Date): string => {
|
||||||
|
return date.toISOString().split("T")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 더하기
|
||||||
|
*/
|
||||||
|
const addDays = (date: Date, days: number): Date => {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setDate(result.getDate() + days);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임라인 데이터를 관리하는 훅
|
||||||
|
*/
|
||||||
|
export function useTimelineData(
|
||||||
|
config: TimelineSchedulerConfig,
|
||||||
|
externalSchedules?: ScheduleItem[],
|
||||||
|
externalResources?: Resource[]
|
||||||
|
): UseTimelineDataResult {
|
||||||
|
// 상태
|
||||||
|
const [schedules, setSchedules] = useState<ScheduleItem[]>([]);
|
||||||
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState<ZoomLevel>(
|
||||||
|
config.defaultZoomLevel || "day"
|
||||||
|
);
|
||||||
|
const [viewStartDate, setViewStartDate] = useState<Date>(() => {
|
||||||
|
if (config.initialDate) {
|
||||||
|
return new Date(config.initialDate);
|
||||||
|
}
|
||||||
|
// 오늘 기준 1주일 전부터 시작
|
||||||
|
const today = new Date();
|
||||||
|
today.setDate(today.getDate() - 7);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return today;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 표시 종료일 계산
|
||||||
|
const viewEndDate = useMemo(() => {
|
||||||
|
const days = zoomLevelDays[zoomLevel];
|
||||||
|
return addDays(viewStartDate, days);
|
||||||
|
}, [viewStartDate, zoomLevel]);
|
||||||
|
|
||||||
|
// 테이블명
|
||||||
|
const tableName = config.useCustomTable
|
||||||
|
? config.customTableName
|
||||||
|
: config.selectedTable;
|
||||||
|
|
||||||
|
const resourceTableName = config.resourceTable;
|
||||||
|
|
||||||
|
// 필드 매핑
|
||||||
|
const fieldMapping = config.fieldMapping || defaultTimelineSchedulerConfig.fieldMapping!;
|
||||||
|
const resourceFieldMapping =
|
||||||
|
config.resourceFieldMapping || defaultTimelineSchedulerConfig.resourceFieldMapping!;
|
||||||
|
|
||||||
|
// 스케줄 데이터 로드
|
||||||
|
const fetchSchedules = useCallback(async () => {
|
||||||
|
if (externalSchedules) {
|
||||||
|
setSchedules(externalSchedules);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
setSchedules([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${tableName}/data`,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
size: 10000,
|
||||||
|
autoFilter: true,
|
||||||
|
search: {
|
||||||
|
// 표시 범위 내의 스케줄만 조회
|
||||||
|
[fieldMapping.startDate]: {
|
||||||
|
value: toDateString(viewEndDate),
|
||||||
|
operator: "lte",
|
||||||
|
},
|
||||||
|
[fieldMapping.endDate]: {
|
||||||
|
value: toDateString(viewStartDate),
|
||||||
|
operator: "gte",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData =
|
||||||
|
response.data?.data?.data || response.data?.data || [];
|
||||||
|
const rawData = Array.isArray(responseData) ? responseData : [];
|
||||||
|
|
||||||
|
// 데이터를 ScheduleItem 형태로 변환
|
||||||
|
const mappedSchedules: ScheduleItem[] = rawData.map((row: any) => ({
|
||||||
|
id: String(row[fieldMapping.id] || ""),
|
||||||
|
resourceId: String(row[fieldMapping.resourceId] || ""),
|
||||||
|
title: String(row[fieldMapping.title] || ""),
|
||||||
|
startDate: row[fieldMapping.startDate] || "",
|
||||||
|
endDate: row[fieldMapping.endDate] || "",
|
||||||
|
status: fieldMapping.status
|
||||||
|
? row[fieldMapping.status] || "planned"
|
||||||
|
: "planned",
|
||||||
|
progress: fieldMapping.progress
|
||||||
|
? Number(row[fieldMapping.progress]) || 0
|
||||||
|
: undefined,
|
||||||
|
color: fieldMapping.color ? row[fieldMapping.color] : undefined,
|
||||||
|
data: row,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSchedules(mappedSchedules);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "스케줄 데이터 로드 중 오류 발생");
|
||||||
|
setSchedules([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
tableName,
|
||||||
|
externalSchedules,
|
||||||
|
fieldMapping,
|
||||||
|
viewStartDate,
|
||||||
|
viewEndDate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 리소스 데이터 로드
|
||||||
|
const fetchResources = useCallback(async () => {
|
||||||
|
if (externalResources) {
|
||||||
|
setResources(externalResources);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resourceTableName) {
|
||||||
|
setResources([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${resourceTableName}/data`,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData =
|
||||||
|
response.data?.data?.data || response.data?.data || [];
|
||||||
|
const rawData = Array.isArray(responseData) ? responseData : [];
|
||||||
|
|
||||||
|
// 데이터를 Resource 형태로 변환
|
||||||
|
const mappedResources: Resource[] = rawData.map((row: any) => ({
|
||||||
|
id: String(row[resourceFieldMapping.id] || ""),
|
||||||
|
name: String(row[resourceFieldMapping.name] || ""),
|
||||||
|
group: resourceFieldMapping.group
|
||||||
|
? row[resourceFieldMapping.group]
|
||||||
|
: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setResources(mappedResources);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("리소스 로드 오류:", err);
|
||||||
|
setResources([]);
|
||||||
|
}
|
||||||
|
}, [resourceTableName, externalResources, resourceFieldMapping]);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSchedules();
|
||||||
|
}, [fetchSchedules]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchResources();
|
||||||
|
}, [fetchResources]);
|
||||||
|
|
||||||
|
// 네비게이션 함수들
|
||||||
|
const goToPrevious = useCallback(() => {
|
||||||
|
const days = zoomLevelDays[zoomLevel];
|
||||||
|
setViewStartDate((prev) => addDays(prev, -days));
|
||||||
|
}, [zoomLevel]);
|
||||||
|
|
||||||
|
const goToNext = useCallback(() => {
|
||||||
|
const days = zoomLevelDays[zoomLevel];
|
||||||
|
setViewStartDate((prev) => addDays(prev, days));
|
||||||
|
}, [zoomLevel]);
|
||||||
|
|
||||||
|
const goToToday = useCallback(() => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setDate(today.getDate() - 7);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
setViewStartDate(today);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToDate = useCallback((date: Date) => {
|
||||||
|
const newDate = new Date(date);
|
||||||
|
newDate.setDate(newDate.getDate() - 7);
|
||||||
|
newDate.setHours(0, 0, 0, 0);
|
||||||
|
setViewStartDate(newDate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 스케줄 업데이트
|
||||||
|
const updateSchedule = useCallback(
|
||||||
|
async (id: string, updates: Partial<ScheduleItem>) => {
|
||||||
|
if (!tableName || !config.editable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 필드 매핑 역변환
|
||||||
|
const updateData: Record<string, any> = {};
|
||||||
|
if (updates.startDate) updateData[fieldMapping.startDate] = updates.startDate;
|
||||||
|
if (updates.endDate) updateData[fieldMapping.endDate] = updates.endDate;
|
||||||
|
if (updates.resourceId) updateData[fieldMapping.resourceId] = updates.resourceId;
|
||||||
|
if (updates.title) updateData[fieldMapping.title] = updates.title;
|
||||||
|
if (updates.status && fieldMapping.status)
|
||||||
|
updateData[fieldMapping.status] = updates.status;
|
||||||
|
if (updates.progress !== undefined && fieldMapping.progress)
|
||||||
|
updateData[fieldMapping.progress] = updates.progress;
|
||||||
|
|
||||||
|
await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, updateData);
|
||||||
|
|
||||||
|
// 로컬 상태 업데이트
|
||||||
|
setSchedules((prev) =>
|
||||||
|
prev.map((s) => (s.id === id ? { ...s, ...updates } : s))
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("스케줄 업데이트 오류:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tableName, fieldMapping, config.editable]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 스케줄 추가
|
||||||
|
const addSchedule = useCallback(
|
||||||
|
async (schedule: Omit<ScheduleItem, "id">) => {
|
||||||
|
if (!tableName || !config.editable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 필드 매핑 역변환
|
||||||
|
const insertData: Record<string, any> = {
|
||||||
|
[fieldMapping.resourceId]: schedule.resourceId,
|
||||||
|
[fieldMapping.title]: schedule.title,
|
||||||
|
[fieldMapping.startDate]: schedule.startDate,
|
||||||
|
[fieldMapping.endDate]: schedule.endDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fieldMapping.status) insertData[fieldMapping.status] = schedule.status;
|
||||||
|
if (fieldMapping.progress && schedule.progress !== undefined)
|
||||||
|
insertData[fieldMapping.progress] = schedule.progress;
|
||||||
|
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${tableName}/data`,
|
||||||
|
insertData
|
||||||
|
);
|
||||||
|
|
||||||
|
const newId = response.data?.data?.id || Date.now().toString();
|
||||||
|
|
||||||
|
// 로컬 상태 업데이트
|
||||||
|
setSchedules((prev) => [...prev, { ...schedule, id: newId }]);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("스케줄 추가 오류:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tableName, fieldMapping, config.editable]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 스케줄 삭제
|
||||||
|
const deleteSchedule = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
if (!tableName || !config.editable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/table-management/tables/${tableName}/data/${id}`);
|
||||||
|
|
||||||
|
// 로컬 상태 업데이트
|
||||||
|
setSchedules((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("스케줄 삭제 오류:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tableName, config.editable]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 새로고침
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
fetchSchedules();
|
||||||
|
fetchResources();
|
||||||
|
}, [fetchSchedules, fetchResources]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
schedules,
|
||||||
|
resources,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
zoomLevel,
|
||||||
|
setZoomLevel,
|
||||||
|
viewStartDate,
|
||||||
|
viewEndDate,
|
||||||
|
goToPrevious,
|
||||||
|
goToNext,
|
||||||
|
goToToday,
|
||||||
|
goToDate,
|
||||||
|
updateSchedule,
|
||||||
|
addSchedule,
|
||||||
|
deleteSchedule,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { TimelineSchedulerComponent } from "./TimelineSchedulerComponent";
|
||||||
|
import { TimelineSchedulerConfigPanel } from "./TimelineSchedulerConfigPanel";
|
||||||
|
import { defaultTimelineSchedulerConfig } from "./config";
|
||||||
|
import { TimelineSchedulerConfig } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2-timeline-scheduler 컴포넌트 정의
|
||||||
|
* 간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트
|
||||||
|
*/
|
||||||
|
export const V2TimelineSchedulerDefinition = createComponentDefinition({
|
||||||
|
id: "v2-timeline-scheduler",
|
||||||
|
name: "타임라인 스케줄러",
|
||||||
|
nameEng: "Timeline Scheduler Component",
|
||||||
|
description: "간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트",
|
||||||
|
category: ComponentCategory.DISPLAY,
|
||||||
|
webType: "text",
|
||||||
|
component: TimelineSchedulerComponent,
|
||||||
|
configPanel: TimelineSchedulerConfigPanel,
|
||||||
|
defaultConfig: defaultTimelineSchedulerConfig as TimelineSchedulerConfig,
|
||||||
|
defaultSize: {
|
||||||
|
width: 1000,
|
||||||
|
height: 500,
|
||||||
|
},
|
||||||
|
icon: "Calendar",
|
||||||
|
tags: ["타임라인", "스케줄", "간트차트", "일정", "계획"],
|
||||||
|
version: "2.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
documentation: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export { TimelineSchedulerComponent } from "./TimelineSchedulerComponent";
|
||||||
|
export { TimelineSchedulerConfigPanel } from "./TimelineSchedulerConfigPanel";
|
||||||
|
export * from "./types";
|
||||||
|
export * from "./config";
|
||||||
|
|
@ -0,0 +1,363 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ComponentConfig } from "@/types/component";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 줌 레벨 (시간 단위)
|
||||||
|
*/
|
||||||
|
export type ZoomLevel = "day" | "week" | "month";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 상태
|
||||||
|
*/
|
||||||
|
export type ScheduleStatus =
|
||||||
|
| "planned"
|
||||||
|
| "in_progress"
|
||||||
|
| "completed"
|
||||||
|
| "delayed"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 항목 (간트 바)
|
||||||
|
*/
|
||||||
|
export interface ScheduleItem {
|
||||||
|
/** 고유 ID */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** 리소스 ID (설비/작업자) */
|
||||||
|
resourceId: string;
|
||||||
|
|
||||||
|
/** 표시 제목 */
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/** 시작 일시 (ISO 8601) */
|
||||||
|
startDate: string;
|
||||||
|
|
||||||
|
/** 종료 일시 (ISO 8601) */
|
||||||
|
endDate: string;
|
||||||
|
|
||||||
|
/** 상태 */
|
||||||
|
status: ScheduleStatus;
|
||||||
|
|
||||||
|
/** 진행률 (0-100) */
|
||||||
|
progress?: number;
|
||||||
|
|
||||||
|
/** 색상 (CSS color) */
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
/** 추가 데이터 */
|
||||||
|
data?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리소스 (행 - 설비/작업자)
|
||||||
|
*/
|
||||||
|
export interface Resource {
|
||||||
|
/** 리소스 ID */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** 표시명 */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** 그룹 (선택) */
|
||||||
|
group?: string;
|
||||||
|
|
||||||
|
/** 아이콘 (선택) */
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/** 용량 (선택, 충돌 계산용) */
|
||||||
|
capacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 매핑 설정
|
||||||
|
*/
|
||||||
|
export interface FieldMapping {
|
||||||
|
/** ID 필드 */
|
||||||
|
id: string;
|
||||||
|
/** 리소스 ID 필드 */
|
||||||
|
resourceId: string;
|
||||||
|
/** 제목 필드 */
|
||||||
|
title: string;
|
||||||
|
/** 시작일 필드 */
|
||||||
|
startDate: string;
|
||||||
|
/** 종료일 필드 */
|
||||||
|
endDate: string;
|
||||||
|
/** 상태 필드 (선택) */
|
||||||
|
status?: string;
|
||||||
|
/** 진행률 필드 (선택) */
|
||||||
|
progress?: string;
|
||||||
|
/** 색상 필드 (선택) */
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리소스 필드 매핑 설정
|
||||||
|
*/
|
||||||
|
export interface ResourceFieldMapping {
|
||||||
|
/** ID 필드 */
|
||||||
|
id: string;
|
||||||
|
/** 이름 필드 */
|
||||||
|
name: string;
|
||||||
|
/** 그룹 필드 (선택) */
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임라인 스케줄러 설정
|
||||||
|
*/
|
||||||
|
export interface TimelineSchedulerConfig extends ComponentConfig {
|
||||||
|
/** 스케줄 데이터 테이블명 */
|
||||||
|
selectedTable?: string;
|
||||||
|
|
||||||
|
/** 리소스 테이블명 */
|
||||||
|
resourceTable?: string;
|
||||||
|
|
||||||
|
/** 스케줄 필드 매핑 */
|
||||||
|
fieldMapping: FieldMapping;
|
||||||
|
|
||||||
|
/** 리소스 필드 매핑 */
|
||||||
|
resourceFieldMapping?: ResourceFieldMapping;
|
||||||
|
|
||||||
|
/** 초기 줌 레벨 */
|
||||||
|
defaultZoomLevel?: ZoomLevel;
|
||||||
|
|
||||||
|
/** 초기 표시 날짜 (ISO 8601) */
|
||||||
|
initialDate?: string;
|
||||||
|
|
||||||
|
/** 편집 가능 여부 */
|
||||||
|
editable?: boolean;
|
||||||
|
|
||||||
|
/** 드래그 이동 가능 */
|
||||||
|
draggable?: boolean;
|
||||||
|
|
||||||
|
/** 리사이즈 가능 */
|
||||||
|
resizable?: boolean;
|
||||||
|
|
||||||
|
/** 행 높이 (px) */
|
||||||
|
rowHeight?: number;
|
||||||
|
|
||||||
|
/** 헤더 높이 (px) */
|
||||||
|
headerHeight?: number;
|
||||||
|
|
||||||
|
/** 리소스 컬럼 너비 (px) */
|
||||||
|
resourceColumnWidth?: number;
|
||||||
|
|
||||||
|
/** 셀 너비 (px, 줌 레벨별) */
|
||||||
|
cellWidth?: {
|
||||||
|
day?: number;
|
||||||
|
week?: number;
|
||||||
|
month?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 충돌 표시 여부 */
|
||||||
|
showConflicts?: boolean;
|
||||||
|
|
||||||
|
/** 진행률 바 표시 여부 */
|
||||||
|
showProgress?: boolean;
|
||||||
|
|
||||||
|
/** 오늘 표시선 */
|
||||||
|
showTodayLine?: boolean;
|
||||||
|
|
||||||
|
/** 상태별 색상 */
|
||||||
|
statusColors?: {
|
||||||
|
planned?: string;
|
||||||
|
in_progress?: string;
|
||||||
|
completed?: string;
|
||||||
|
delayed?: string;
|
||||||
|
cancelled?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 툴바 표시 여부 */
|
||||||
|
showToolbar?: boolean;
|
||||||
|
|
||||||
|
/** 줌 레벨 변경 버튼 표시 */
|
||||||
|
showZoomControls?: boolean;
|
||||||
|
|
||||||
|
/** 네비게이션 버튼 표시 */
|
||||||
|
showNavigation?: boolean;
|
||||||
|
|
||||||
|
/** 추가 버튼 표시 */
|
||||||
|
showAddButton?: boolean;
|
||||||
|
|
||||||
|
/** 높이 (px 또는 auto) */
|
||||||
|
height?: number | string;
|
||||||
|
|
||||||
|
/** 최대 높이 */
|
||||||
|
maxHeight?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드래그 이벤트
|
||||||
|
*/
|
||||||
|
export interface DragEvent {
|
||||||
|
/** 스케줄 ID */
|
||||||
|
scheduleId: string;
|
||||||
|
/** 새로운 시작일 */
|
||||||
|
newStartDate: string;
|
||||||
|
/** 새로운 종료일 */
|
||||||
|
newEndDate: string;
|
||||||
|
/** 새로운 리소스 ID (리소스 간 이동 시) */
|
||||||
|
newResourceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리사이즈 이벤트
|
||||||
|
*/
|
||||||
|
export interface ResizeEvent {
|
||||||
|
/** 스케줄 ID */
|
||||||
|
scheduleId: string;
|
||||||
|
/** 새로운 시작일 */
|
||||||
|
newStartDate: string;
|
||||||
|
/** 새로운 종료일 */
|
||||||
|
newEndDate: string;
|
||||||
|
/** 리사이즈 방향 */
|
||||||
|
direction: "start" | "end";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클릭 이벤트
|
||||||
|
*/
|
||||||
|
export interface ScheduleClickEvent {
|
||||||
|
/** 스케줄 항목 */
|
||||||
|
schedule: ScheduleItem;
|
||||||
|
/** 리소스 */
|
||||||
|
resource: Resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 셀 클릭 이벤트
|
||||||
|
*/
|
||||||
|
export interface CellClickEvent {
|
||||||
|
/** 리소스 ID */
|
||||||
|
resourceId: string;
|
||||||
|
/** 날짜 */
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TimelineSchedulerComponent Props
|
||||||
|
*/
|
||||||
|
export interface TimelineSchedulerComponentProps {
|
||||||
|
/** 컴포넌트 설정 */
|
||||||
|
config: TimelineSchedulerConfig;
|
||||||
|
|
||||||
|
/** 디자인 모드 여부 */
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
|
||||||
|
/** 폼 데이터 */
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
|
||||||
|
/** 외부 스케줄 데이터 */
|
||||||
|
externalSchedules?: ScheduleItem[];
|
||||||
|
|
||||||
|
/** 외부 리소스 데이터 */
|
||||||
|
externalResources?: Resource[];
|
||||||
|
|
||||||
|
/** 로딩 상태 */
|
||||||
|
isLoading?: boolean;
|
||||||
|
|
||||||
|
/** 에러 */
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
/** 컴포넌트 ID */
|
||||||
|
componentId?: string;
|
||||||
|
|
||||||
|
/** 드래그 완료 이벤트 */
|
||||||
|
onDragEnd?: (event: DragEvent) => void;
|
||||||
|
|
||||||
|
/** 리사이즈 완료 이벤트 */
|
||||||
|
onResizeEnd?: (event: ResizeEvent) => void;
|
||||||
|
|
||||||
|
/** 스케줄 클릭 이벤트 */
|
||||||
|
onScheduleClick?: (event: ScheduleClickEvent) => void;
|
||||||
|
|
||||||
|
/** 빈 셀 클릭 이벤트 */
|
||||||
|
onCellClick?: (event: CellClickEvent) => void;
|
||||||
|
|
||||||
|
/** 스케줄 추가 이벤트 */
|
||||||
|
onAddSchedule?: (resourceId: string, date: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useTimelineData 훅 반환 타입
|
||||||
|
*/
|
||||||
|
export interface UseTimelineDataResult {
|
||||||
|
/** 스케줄 목록 */
|
||||||
|
schedules: ScheduleItem[];
|
||||||
|
|
||||||
|
/** 리소스 목록 */
|
||||||
|
resources: Resource[];
|
||||||
|
|
||||||
|
/** 로딩 상태 */
|
||||||
|
isLoading: boolean;
|
||||||
|
|
||||||
|
/** 에러 */
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
/** 현재 줌 레벨 */
|
||||||
|
zoomLevel: ZoomLevel;
|
||||||
|
|
||||||
|
/** 줌 레벨 변경 */
|
||||||
|
setZoomLevel: (level: ZoomLevel) => void;
|
||||||
|
|
||||||
|
/** 현재 표시 시작일 */
|
||||||
|
viewStartDate: Date;
|
||||||
|
|
||||||
|
/** 현재 표시 종료일 */
|
||||||
|
viewEndDate: Date;
|
||||||
|
|
||||||
|
/** 이전으로 이동 */
|
||||||
|
goToPrevious: () => void;
|
||||||
|
|
||||||
|
/** 다음으로 이동 */
|
||||||
|
goToNext: () => void;
|
||||||
|
|
||||||
|
/** 오늘로 이동 */
|
||||||
|
goToToday: () => void;
|
||||||
|
|
||||||
|
/** 특정 날짜로 이동 */
|
||||||
|
goToDate: (date: Date) => void;
|
||||||
|
|
||||||
|
/** 스케줄 업데이트 */
|
||||||
|
updateSchedule: (id: string, updates: Partial<ScheduleItem>) => Promise<void>;
|
||||||
|
|
||||||
|
/** 스케줄 추가 */
|
||||||
|
addSchedule: (schedule: Omit<ScheduleItem, "id">) => Promise<void>;
|
||||||
|
|
||||||
|
/** 스케줄 삭제 */
|
||||||
|
deleteSchedule: (id: string) => Promise<void>;
|
||||||
|
|
||||||
|
/** 데이터 새로고침 */
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 셀 정보
|
||||||
|
*/
|
||||||
|
export interface DateCell {
|
||||||
|
/** 날짜 */
|
||||||
|
date: Date;
|
||||||
|
/** 표시 라벨 */
|
||||||
|
label: string;
|
||||||
|
/** 오늘 여부 */
|
||||||
|
isToday: boolean;
|
||||||
|
/** 주말 여부 */
|
||||||
|
isWeekend: boolean;
|
||||||
|
/** 월 첫째날 여부 */
|
||||||
|
isMonthStart: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 바 위치 정보
|
||||||
|
*/
|
||||||
|
export interface ScheduleBarPosition {
|
||||||
|
/** 왼쪽 오프셋 (px) */
|
||||||
|
left: number;
|
||||||
|
/** 너비 (px) */
|
||||||
|
width: number;
|
||||||
|
/** 상단 오프셋 (px) */
|
||||||
|
top: number;
|
||||||
|
/** 높이 (px) */
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
@ -66,6 +66,8 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData |
|
||||||
codeCategory: overrides.codeCategory,
|
codeCategory: overrides.codeCategory,
|
||||||
inputType: overrides.inputType,
|
inputType: overrides.inputType,
|
||||||
webType: overrides.webType,
|
webType: overrides.webType,
|
||||||
|
// 🆕 autoFill 설정 복원 (자동 입력 기능)
|
||||||
|
autoFill: overrides.autoFill,
|
||||||
// 기존 구조 호환을 위한 추가 필드
|
// 기존 구조 호환을 위한 추가 필드
|
||||||
style: {},
|
style: {},
|
||||||
parentId: null,
|
parentId: null,
|
||||||
|
|
@ -115,6 +117,8 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 {
|
||||||
if (comp.codeCategory) topLevelProps.codeCategory = comp.codeCategory;
|
if (comp.codeCategory) topLevelProps.codeCategory = comp.codeCategory;
|
||||||
if (comp.inputType) topLevelProps.inputType = comp.inputType;
|
if (comp.inputType) topLevelProps.inputType = comp.inputType;
|
||||||
if (comp.webType) topLevelProps.webType = comp.webType;
|
if (comp.webType) topLevelProps.webType = comp.webType;
|
||||||
|
// 🆕 autoFill 설정 저장 (자동 입력 기능)
|
||||||
|
if (comp.autoFill) topLevelProps.autoFill = comp.autoFill;
|
||||||
|
|
||||||
// 현재 설정에서 차이값만 추출
|
// 현재 설정에서 차이값만 추출
|
||||||
const fullConfig = comp.componentConfig || {};
|
const fullConfig = comp.componentConfig || {};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue