diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 7f79d998..4fa08eed 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -671,14 +671,14 @@ export async function getTableRecord( logger.info(`필터: ${filterColumn} = ${filterValue}`); logger.info(`표시 컬럼: ${displayColumn}`); - if (!tableName || !filterColumn || !filterValue || !displayColumn) { + if (!tableName || !filterColumn || !filterValue) { const response: ApiResponse = { success: false, message: "필수 파라미터가 누락되었습니다.", error: { code: "MISSING_PARAMETERS", details: - "tableName, filterColumn, filterValue, displayColumn이 필요합니다.", + "tableName, filterColumn, filterValue가 필요합니다. displayColumn은 선택적입니다.", }, }; res.status(400).json(response); @@ -710,9 +710,12 @@ export async function getTableRecord( } 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 }> = { success: true, diff --git a/docs/V2_컴포넌트_분석_가이드.md b/docs/V2_컴포넌트_분석_가이드.md index 55598e02..451cf602 100644 --- a/docs/V2_컴포넌트_분석_가이드.md +++ b/docs/V2_컴포넌트_분석_가이드.md @@ -26,11 +26,12 @@ frontend/lib/registry/components/ --- -## V2 컴포넌트 목록 (17개) +## V2 컴포넌트 목록 (18개) | 컴포넌트 ID | 이름 | 카테고리 | 용도 | |------------|------|----------|------| | `v2-table-list` | 테이블 리스트 | DISPLAY | 데이터 목록 표시 (테이블/카드 모드) | +| `v2-table-grouped` | 그룹화 테이블 | DISPLAY | **NEW** 그룹별 접기/펼치기 테이블 | | `v2-split-panel-layout` | 분할 패널 | DISPLAY | 마스터-디테일 레이아웃 | | `v2-unified-repeater` | 통합 리피터 | UNIFIED | 반복 데이터 관리 (인라인/모달/버튼) | | `v2-pivot-grid` | 피벗 그리드 | DISPLAY | 다차원 데이터 분석 피벗 테이블 | @@ -182,12 +183,43 @@ interface ColumnConfig { #### 주요 특징 -- 좌측: 마스터 목록 (리스트/테이블 모드) +- 좌측: 마스터 목록 (리스트/테이블/커스텀 모드) - 우측: 디테일 정보 (연관 데이터) - 좌우 비율 조절 가능 (드래그 리사이즈) - 다중 탭 지원 (우측 패널) - 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; + style?: Record; +} +``` #### 데이터 흐름 @@ -220,7 +252,7 @@ interface SplitPanelLayoutConfig { tableName?: string; useCustomTable?: boolean; customTableName?: string; - displayMode?: "list" | "table"; + displayMode?: "list" | "table" | "custom"; // 🆕 커스텀 모드 추가 showSearch?: boolean; showAdd?: boolean; showEdit?: boolean; @@ -228,15 +260,21 @@ interface SplitPanelLayoutConfig { columns?: ColumnConfig[]; tableConfig?: TableDisplayConfig; dataFilter?: DataFilterConfig; + + // 🆕 커스텀 모드 전용: 내부 컴포넌트 배열 + components?: PanelInlineComponent[]; }; // 우측 패널 rightPanel: { title: string; tableName?: string; - displayMode?: "list" | "table"; + displayMode?: "list" | "table" | "custom"; // 🆕 커스텀 모드 추가 columns?: ColumnConfig[]; + // 🆕 커스텀 모드 전용: 내부 컴포넌트 배열 + components?: PanelInlineComponent[]; + // 관계 설정 relation?: { type?: "join" | "detail"; diff --git a/docs/V2_컴포넌트_연동_가이드.md b/docs/V2_컴포넌트_연동_가이드.md index f6756a5e..a8496926 100644 --- a/docs/V2_컴포넌트_연동_가이드.md +++ b/docs/V2_컴포넌트_연동_가이드.md @@ -959,6 +959,29 @@ receiveData(data, config): Promise **구독 이벤트**: - `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. 연동 가능한 조합 diff --git a/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md b/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md new file mode 100644 index 00000000..58c8cd3f --- /dev/null +++ b/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md @@ -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; +} +``` + +### 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; +} + +/** + * 리소스 (행) + */ +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; + + /** 리소스 컬럼 너비 */ + 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 diff --git a/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md b/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md index 1f18e804..e32e68cc 100644 --- a/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md +++ b/docs/screen-implementation-guide/00_analysis/v2-component-usage-guide.md @@ -63,7 +63,7 @@ | ID | 이름 | 용도 | 주요 옵션 | |----|------|------|----------| -| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, relation | +| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, relation, **displayMode: custom** | | `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs, activeTabId | | `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding | | `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 필수 설정 ```typescript diff --git a/docs/screen-implementation-guide/03_production/production-plan.md b/docs/screen-implementation-guide/03_production/production-plan.md index 22ef6a7a..aaba82b6 100644 --- a/docs/screen-implementation-guide/03_production/production-plan.md +++ b/docs/screen-implementation-guide/03_production/production-plan.md @@ -1,6 +1,13 @@ # 생산계획관리 (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 | -| **화면 유형** | 복합형 (좌우 분할 패널 + 타임라인 스케줄러) | +| **Screen ID** | /screens/{TBD} | +| **메뉴 경로** | 생산관리 > 생산계획관리 | +| **화면 유형 (전체)** | 복합형 (좌우 분할 패널 + 타임라인 스케줄러) - ⚠️ 컴포넌트 확장 후 구현 | +| **화면 유형 (간소화)** | **패턴 A** (기본 마스터 화면) - ✅ 즉시 구현 가능 | | **메인 테이블** | `production_plan_mng` | | **관련 테이블** | `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. **수주데이터**: 수주 목록 (품목별 그룹핑) @@ -117,54 +169,119 @@ | 기본 버튼 (엑셀, 사용자옵션) | ✅ | `v2-button-primary` | | | 단일 테이블 목록 | ✅ | `v2-table-list` | | | 기본 모달 폼 | ✅ | 모달 화면 | | -| 좌우 분할 패널 (기본) | ⚠️ | `v2-split-panel-layout` | 테이블/리스트만 표시 가능 | +| 좌우 분할 패널 (커스텀 모드) | ✅ | `v2-split-panel-layout` | **displayMode: "custom" 지원** | | 탭 컴포넌트 (기본) | ⚠️ | `v2-tabs-widget` | 디자인 모드에서 컴포넌트 배치 | -### 4.2 현재 V2 컴포넌트의 제한 사항 +### 4.2 현재 V2 컴포넌트의 제한 사항 및 확장 방안 -#### `v2-split-panel-layout` 제한 +#### `v2-split-panel-layout` 현재 기능 -**현재 기능**: -- 좌우 분할 패널 (리사이즈 가능) -- 각 패널에 **테이블** 또는 **리스트** 표시 (`displayMode: "list" | "table"`) -- leftPanel ↔ rightPanel 관계 설정 (relation) -- 우측 패널에 추가 탭 (additionalTabs) +| 기능 | 지원 | 설명 | +|------|:----:|------| +| 좌우 분할 패널 | ✅ | 리사이즈 가능 | +| 테이블/리스트 표시 | ✅ | `displayMode: "list" \| "table"` | +| 마스터-디테일 관계 | ✅ | `relation` 설정 | +| 우측 추가 탭 (`additionalTabs`) | ⚠️ | **타입만 정의, 렌더링 미구현** | +| **패널 내 임의 컴포넌트 배치** | ✅ | **`displayMode: "custom"` - 구현 완료!** | -**제한 사항**: -- ❌ 패널 안에 **임의의 컴포넌트** (타임라인 등)를 배치할 수 없음 -- ❌ **그룹화된 테이블** (접기/펼치기) 미지원 -- ❌ 복잡한 커스텀 UI 배치 불가 +#### ✅ 커스텀 모드 (구현 완료) -#### `v2-tabs-widget` 제한 +`v2-tabs-widget`과 동일한 구조로 패널 안에 컴포넌트를 자유롭게 배치: -**현재 기능**: -- 탭별 컴포넌트 자유 배치 -- 디자인 모드에서 드래그&드롭 +```typescript +leftPanel: { + displayMode: "list" | "table" | "custom", // ✅ 커스텀 모드 지원 + + // 기존 모드 (list, table) + tableName?: string, + columns?: [...], + + // ✅ 커스텀 모드 - 패널 안에 자유롭게 컴포넌트 배치 + components?: PanelInlineComponent[], +} -**제한 사항**: -- ❌ 탭 내에 **다른 V2 컴포넌트**를 완전히 자유롭게 배치하기 어려움 -- ❌ 런타임에서 복잡한 컴포넌트 렌더링 제한 +// PanelInlineComponent 구조 +interface PanelInlineComponent { + id: string; + componentType: string; + label?: string; + position?: { x: number; y: number }; + size?: { width: number; height: number }; + componentConfig?: Record; +} +``` + +**커스텀 모드 기능**: +- ✅ 드래그앤드롭으로 컴포넌트 이동 +- ✅ 리사이즈 핸들로 크기 조절 +- ✅ 컴포넌트 클릭 시 좌측 설정 패널에서 속성 편집 +- ✅ 디자인 모드에서 실제 컴포넌트 미리보기 렌더링 +- ✅ 탭, 테이블, 버튼 등 모든 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; +} +``` ### 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 - 타임라인/간트차트 스케줄러 (핵심!) -2. v2-table-grouped - 그룹화된 테이블 (접기/펼치기) -3. v2-split-panel-enhanced - 패널 내 임의 컴포넌트 배치 가능한 확장판 -4. v2-modal-complex - 복잡한 모달 (분할, 다단계, 설비할당) +최종 필요 작업: +1. ✅ v2-split-panel-layout 확장 - displayMode: "custom" 추가 [완료] +2. ✅ v2-table-grouped 신규 개발 - 그룹화 테이블 (접기/펼치기) [완료] +3. v2-timeline-scheduler 신규 개발 - 타임라인/간트차트 (핵심!) ``` ### 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. 단순화된 구현 방안 (임시) @@ -211,42 +385,107 @@ └──────────────────────────────────────────────────────────────┘ ``` -### 5.2 간소화 버전 테이블 컬럼 +### 5.2 컴포넌트 배치도 (간소화 버전) -| 순서 | 컬럼명 | 표시명 | 정렬 | 형식 | -|:----:|--------|--------|:----:|------| -| 1 | plan_no | 계획번호 | left | text | -| 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 | -| 7 | end_date | 종료일 | center | date | -| 8 | due_date | 납기일 | center | date | -| 9 | equipment_name | 설비 | left | text | -| 10 | status | 상태 | center | badge | -| 11 | manager_name | 담당자 | left | text | -| 12 | product_type | 제품유형 | center | text | +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ v2-table-search-widget (comp_search) │ +│ [품목코드] [품명] [계획기간: 시작~종료] [상태] [조회] [엑셀다운로드] │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ 생산계획 목록 [신규등록] [수정] [삭제] [작업지시 생성] │ +│ ─────────────────────────────────────────────────────────────────────────── │ +│ v2-table-list (comp_table) │ +│ □ | 계획번호 | 계획일자 | 품목코드 | 품명 | 계획수량 | 시작일 | 종료일 | ... │ +│ □ | PP-001 | 2026-01 | ITEM001 | 제품A| 1,000 | 01-30 | 02-05 | ... │ +│ □ | PP-002 | 2026-01 | ITEM002 | 제품B| 500 | 02-01 | 02-10 | ... │ +│ │ +│ [1] [2] [3] ... [10] 페이지당: [20] ▼ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` -### 5.3 간소화 버전 모달 필드 +### 5.3 컴포넌트 목록 (간소화 버전) -| 필드명 | 라벨 | 타입 | 필수 | -|--------|------|------|:----:| -| plan_no | 계획번호 | text | ✅ | -| plan_date | 계획일자 | date | ✅ | -| item_code | 품목코드 | select (품목 검색) | ✅ | -| item_name | 품명 | text (자동) | | -| plan_qty | 계획수량 | number | ✅ | -| start_date | 시작일 | date | ✅ | -| end_date | 종료일 | date | ✅ | -| due_date | 납기일 | date | | -| equipment_id | 설비 | select | | -| status | 상태 | select | ✅ | -| priority | 우선순위 | select | | -| work_shift | 작업조 | select | | -| manager_name | 담당자 | text | | -| remarks | 비고 | textarea | | -| product_type | 제품유형 | select | | +| 컴포넌트 ID | 컴포넌트 타입 | 역할 | +|-------------|---------------|------| +| `comp_search` | v2-table-search-widget | 검색 필터 | +| `comp_table` | v2-table-list | 생산계획 목록 | +| `comp_btn_register` | v2-button-primary | 신규 등록 | +| `comp_btn_edit` | v2-button-primary | 수정 | +| `comp_btn_delete` | v2-button-primary | 삭제 | +| `comp_btn_work_order` | v2-button-primary | 작업지시 생성 | + +### 5.4 간소화 버전 테이블 컬럼 + +| 순서 | 컬럼명 | 표시명 | 정렬 | 형식 | 너비 | +|:----:|--------|--------|:----:|------|:----:| +| 1 | plan_no | 계획번호 | left | text | 120 | +| 2 | plan_date | 계획일자 | center | date | 100 | +| 3 | item_code | 품목코드 | left | text | 100 | +| 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` 컴포넌트 개발 -2. `v2-split-panel` 컴포넌트 개발 -3. `v2-table-grouped` 컴포넌트 개발 +1. ~~**`v2-split-panel-layout` 확장**~~ ✅ **완료** + - ✅ `displayMode: "custom"` 추가 + - ✅ `components[]` 필드 지원 (탭 컴포넌트와 동일한 구조) + - ✅ 디자인 모드에서 컴포넌트 드래그&드롭 지원 + - ✅ 컴포넌트 클릭 시 좌측 설정 패널에서 속성 편집 + +2. ~~**`v2-table-grouped` 신규 개발**~~ ✅ **완료** + - ✅ 품목별 그룹핑 (접기/펼치기) + - ✅ 그룹 행과 상세 행 구분 + - ✅ 체크박스 선택 (그룹 단위 / 개별 단위) + - ✅ 그룹 요약 (개수, 합계, 평균 등) ### Phase 3: 타임라인 스케줄러 -1. `v2-timeline-scheduler` 컴포넌트 개발 -2. 드래그&드롭 기능 -3. 자동 스케줄 생성 로직 -4. 반제품 연동 +1. **`v2-timeline-scheduler` 신규 개발** + - 간트차트 형태 타임라인 + - 날짜 그리드 (일/주/월 단위) + - 생산계획 바 렌더링 + +2. **드래그&드롭 기능** + - 계획 바 드래그로 날짜 이동 + - 계획 바 크기 조정 (기간 변경) + +3. **비즈니스 로직** + - 자동 스케줄 생성 API + - BOM 기반 반제품 계획 연동 + - 설비별 용량 체크 --- @@ -488,7 +744,25 @@ V2 컴포넌트로 기본 CRUD 화면 구현: "position": { "x": 0, "y": 20, "z": 1 }, "overrides": { "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 }, @@ -501,23 +775,39 @@ V2 컴포넌트로 기본 CRUD 화면 구현: "type": "v2-table-list", "label": "생산계획 목록", "columns": [ - { "columnName": "plan_no", "displayName": "계획번호", "order": 0, "visible": true, "sortable": true, "format": "text", "align": "left" }, - { "columnName": "plan_date", "displayName": "계획일자", "order": 1, "visible": true, "sortable": true, "format": "date", "align": "center" }, - { "columnName": "item_code", "displayName": "품목코드", "order": 2, "visible": true, "sortable": true, "format": "text", "align": "left" }, - { "columnName": "item_name", "displayName": "품명", "order": 3, "visible": true, "sortable": true, "format": "text", "align": "left" }, - { "columnName": "plan_qty", "displayName": "계획수량", "order": 4, "visible": true, "sortable": true, "format": "number", "align": "right" }, - { "columnName": "start_date", "displayName": "시작일", "order": 5, "visible": true, "sortable": true, "format": "date", "align": "center" }, - { "columnName": "end_date", "displayName": "종료일", "order": 6, "visible": true, "sortable": true, "format": "date", "align": "center" }, - { "columnName": "due_date", "displayName": "납기일", "order": 7, "visible": true, "sortable": true, "format": "date", "align": "center" }, - { "columnName": "equipment_name", "displayName": "설비", "order": 8, "visible": true, "sortable": true, "format": "text", "align": "left" }, - { "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": "manager_name", "displayName": "담당자", "order": 11, "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", "width": 100 }, + { "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", "width": 150 }, + { "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", "width": 100 }, + { "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", "width": 100 }, + { "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": "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", "pagination": { "enabled": true, "pageSize": 20, "pageSizeOptions": [10, 20, 50, 100] }, "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 }, @@ -525,7 +815,7 @@ V2 컴포넌트로 기본 CRUD 화면 구현: "id": "comp_btn_register", "url": "@/lib/registry/components/v2-button-primary", "size": { "width": 100, "height": 40 }, - "position": { "x": 1580, "y": 70, "z": 1 }, + "position": { "x": 1480, "y": 70, "z": 1 }, "overrides": { "text": "신규 등록", "type": "v2-button-primary", @@ -538,12 +828,13 @@ V2 컴포넌트로 기본 CRUD 화면 구현: "id": "comp_btn_edit", "url": "@/lib/registry/components/v2-button-primary", "size": { "width": 80, "height": 40 }, - "position": { "x": 1690, "y": 70, "z": 1 }, + "position": { "x": 1590, "y": 70, "z": 1 }, "overrides": { "text": "수정", "type": "v2-button-primary", "action": { "type": "edit", "modalTitle": "생산계획 수정", "targetScreenId": null }, - "variant": "secondary" + "variant": "secondary", + "requireSelection": true }, "displayOrder": 3 }, @@ -551,14 +842,34 @@ V2 컴포넌트로 기본 CRUD 화면 구현: "id": "comp_btn_delete", "url": "@/lib/registry/components/v2-button-primary", "size": { "width": 80, "height": 40 }, - "position": { "x": 1780, "y": 70, "z": 1 }, + "position": { "x": 1680, "y": 70, "z": 1 }, "overrides": { "text": "삭제", "type": "v2-button-primary", "action": { "type": "delete" }, - "variant": "danger" + "variant": "danger", + "requireSelection": true, + "enableDataflowControl": true, + "dataflowDiagramId": null }, "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_layouts_v2` INSERT | +| ☐ | **제어관리 설정** | `dataflow_diagrams` INSERT (삭제, 작업지시 생성) | | ☐ | **메뉴 연결** | 대상 메뉴에 화면 할당 | | ☐ | **기본 CRUD 테스트** | 등록/수정/삭제/조회 테스트 | +| ☐ | **비즈니스 룰 테스트** | 조건부 삭제, 작업지시 생성 테스트 | -### 10.2 전체 버전 (향후 구현) +### 15.2 전체 버전 (향후 구현) | 체크 | 항목 | 설명 | |:----:|------|------| -| ☐ | **v2-tabs 개발** | 탭 컴포넌트 개발 | -| ☐ | **v2-split-panel 개발** | 분할 패널 컴포넌트 개발 | -| ☐ | **v2-table-grouped 개발** | 그룹화 테이블 컴포넌트 개발 | -| ☐ | **v2-timeline-scheduler 개발** | 타임라인 스케줄러 개발 | +| ✅ | ~~**v2-split-panel-layout 확장**~~ | `displayMode: "custom"` + `components[]` 추가 **완료** | +| ✅ | ~~**v2-table-grouped 개발**~~ | 그룹화 테이블 컴포넌트 **완료** | +| ☐ | **v2-timeline-scheduler 개발** | 타임라인/간트차트 스케줄러 | +| ☐ | **드래그&드롭 기능** | 타임라인 바 드래그 이동/크기 조정 | | ☐ | **자동 스케줄 로직** | 자동 스케줄 생성 백엔드 API | | ☐ | **반제품 연동** | BOM 기반 반제품 계획 생성 | -| ☐ | **드래그&드롭** | 타임라인 드래그 이동 기능 | +| ☐ | **정식 버전 화면 구성** | 커스텀 모드로 탭+테이블+타임라인 조합 | --- -## 11. 관련 문서 +## 16. 관련 문서 - [수주관리](../02_sales/order.md) - [품목정보](../01_master-data/item-info.md) -- [설비관리](../05_equipment/equipment-info.md) (예정) -- [BOM관리](../01_master-data/bom-info.md) (예정) +- [설비관리](../05_equipment/equipment-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) diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index eb7ecce5..b86facfd 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -202,7 +202,89 @@ function ScreenViewPage() { } }, [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 = {}; + 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(() => { const initAutoFill = async () => { if (!layout || !layout.components || !user) { @@ -215,7 +297,7 @@ function ScreenViewPage() { const widget = comp as any; const fieldName = widget.columnName || widget.id; - // autoFill 처리 + // autoFill 처리 (명시적으로 설정된 경우만) if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) { const autoFillConfig = widget.autoFill || (comp as any).autoFill; const currentValue = formData[fieldName]; diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 75eec128..886e3977 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -39,6 +39,8 @@ interface RealtimePreviewProps { onUpdateComponent?: (updatedComponent: any) => void; // 🆕 컴포넌트 업데이트 콜백 onSelectTabComponent?: (tabId: string, compId: string, comp: any) => void; // 🆕 탭 내부 컴포넌트 선택 콜백 selectedTabComponentId?: string; // 🆕 선택된 탭 컴포넌트 ID + onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; // 🆕 분할 패널 내부 컴포넌트 선택 콜백 + selectedPanelComponentId?: string; // 🆕 선택된 분할 패널 컴포넌트 ID onResize?: (componentId: string, newSize: { width: number; height: number }) => void; // 🆕 리사이즈 콜백 // 버튼 액션을 위한 props @@ -140,6 +142,8 @@ const RealtimePreviewDynamicComponent: React.FC = ({ onUpdateComponent, // 🆕 컴포넌트 업데이트 콜백 onSelectTabComponent, // 🆕 탭 내부 컴포넌트 선택 콜백 selectedTabComponentId, // 🆕 선택된 탭 컴포넌트 ID + onSelectPanelComponent, // 🆕 분할 패널 내부 컴포넌트 선택 콜백 + selectedPanelComponentId, // 🆕 선택된 분할 패널 컴포넌트 ID onResize, // 🆕 리사이즈 콜백 }) => { // 🆕 화면 다국어 컨텍스트 @@ -640,6 +644,8 @@ const RealtimePreviewDynamicComponent: React.FC = ({ onUpdateComponent={onUpdateComponent} onSelectTabComponent={onSelectTabComponent} selectedTabComponentId={selectedTabComponentId} + onSelectPanelComponent={onSelectPanelComponent} + selectedPanelComponentId={selectedPanelComponentId} /> diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index b05f03b6..a0ed5574 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -177,13 +177,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU component: any; // 탭 내부 컴포넌트 데이터 } | null>(null); + // 🆕 분할 패널 내부 컴포넌트 선택 상태 + const [selectedPanelComponentInfo, setSelectedPanelComponentInfo] = useState<{ + splitPanelId: string; // 분할 패널 컴포넌트 ID + panelSide: "left" | "right"; // 좌측/우측 패널 + componentId: string; // 패널 내부 컴포넌트 ID + component: any; // 패널 내부 컴포넌트 데이터 + } | null>(null); + // 컴포넌트 선택 시 통합 패널 자동 열기 const handleComponentSelect = useCallback( (component: ComponentData | null) => { setSelectedComponent(component); - // 일반 컴포넌트 선택 시 탭 내부 컴포넌트 선택 해제 + // 일반 컴포넌트 선택 시 탭 내부 컴포넌트/분할 패널 내부 컴포넌트 선택 해제 if (component) { setSelectedTabComponentInfo(null); + setSelectedPanelComponentInfo(null); } // 컴포넌트가 선택되면 통합 패널 자동 열기 @@ -209,8 +218,32 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU componentId: compId, component: comp, }); - // 탭 내부 컴포넌트 선택 시 일반 컴포넌트 선택 해제 + // 탭 내부 컴포넌트 선택 시 일반 컴포넌트/분할 패널 내부 컴포넌트 선택 해제 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], @@ -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(); 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 = { + 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(); if (!rect) return; @@ -5123,6 +5321,158 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU ); })() + ) : 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 ( +
+
+ + 분할 패널 ({selectedPanelComponentInfo.panelSide === "left" ? "좌측" : "우측"}) 컴포넌트 + + +
+
+ 0 ? tables[0] : undefined} + currentTableName={selectedScreen?.tableName} + currentScreenCompanyCode={selectedScreen?.companyCode} + onStyleChange={(style) => { + updatePanelComponentProperty(panelComp.id, "style", style); + }} + allComponents={layout.components} + menuObjid={menuObjid} + /> +
+
+ ); + })() ) : ( + handleSelectPanelComponent(component.id, panelSide, compId, comp) + } + selectedPanelComponentId={ + selectedPanelComponentInfo?.splitPanelId === component.id + ? selectedPanelComponentInfo.componentId + : undefined + } > {/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} {(component.type === "group" || diff --git a/frontend/components/screen/panels/TablesPanel.tsx b/frontend/components/screen/panels/TablesPanel.tsx index 480222a6..12dcc19a 100644 --- a/frontend/components/screen/panels/TablesPanel.tsx +++ b/frontend/components/screen/panels/TablesPanel.tsx @@ -431,7 +431,8 @@ export const TablesPanel: React.FC = ({ } return ( -
+ // 엔티티 조인 테이블에 고유 접두사 추가 (메인 테이블과 키 중복 방지) +
{/* 조인 테이블 헤더 */}
void; selectedTabComponentId?: string; + // 🆕 분할 패널 내부 컴포넌트 선택 콜백 + onSelectPanelComponent?: (panelSide: "left" | "right", compId: string, comp: any) => void; + selectedPanelComponentId?: string; flowSelectedStepId?: number | null; onFlowSelectedDataChange?: (selectedData: any[], stepId: number | null) => void; // 테이블 새로고침 키 @@ -502,6 +505,9 @@ export const DynamicComponentRenderer: React.FC = // 🆕 탭 내부 컴포넌트 선택 콜백 onSelectTabComponent: props.onSelectTabComponent, selectedTabComponentId: props.selectedTabComponentId, + // 🆕 분할 패널 내부 컴포넌트 선택 콜백 + onSelectPanelComponent: props.onSelectPanelComponent, + selectedPanelComponentId: props.selectedPanelComponentId, }; // 렌더러가 클래스인지 함수인지 확인 diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 593c3e8b..6519230d 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -106,6 +106,8 @@ import "./v2-table-search-widget"; import "./v2-tabs-widget/tabs-component"; import "./v2-category-manager/V2CategoryManagerRenderer"; import "./v2-media/V2MediaRenderer"; // V2 통합 미디어 컴포넌트 +import "./v2-table-grouped/TableGroupedRenderer"; // 그룹화 테이블 +import "./v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스케줄러 import "./v2-input/V2InputRenderer"; // V2 통합 입력 컴포넌트 import "./v2-select/V2SelectRenderer"; // V2 통합 선택 컴포넌트 import "./v2-date/V2DateRenderer"; // V2 통합 날짜 컴포넌트 diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 725f6bc0..8a5898e0 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -18,6 +18,8 @@ import { ChevronRight, Pencil, Trash2, + Settings, + Move, } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; @@ -37,9 +39,16 @@ import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options"; import { useAuth } from "@/hooks/useAuth"; import { useSplitPanel } from "./SplitPanelContext"; +import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; +import { PanelInlineComponent } from "./types"; +import { cn } from "@/lib/utils"; export interface SplitPanelLayoutComponentProps extends ComponentRendererProps { // 추가 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 isSelected = false, isPreview = false, onClick, + onUpdateComponent, + onSelectPanelComponent, + selectedPanelComponentId: externalSelectedPanelComponentId, ...props }) => { const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig; @@ -181,6 +193,207 @@ export const SplitPanelLayoutComponent: React.FC const [rightCategoryMappings, setRightCategoryMappings] = useState< Record> >({}); // 우측 카테고리 매핑 + + // 🆕 커스텀 모드: 드래그/리사이즈 상태 + const [draggingCompId, setDraggingCompId] = useState(null); + const [dragPosition, setDragPosition] = useState<{ x: number; y: number } | null>(null); + const [resizingCompId, setResizingCompId] = useState(null); + const [resizeSize, setResizeSize] = useState<{ width: number; height: number } | null>(null); + // 🆕 외부에서 전달받은 선택 상태 사용 (탭 컴포넌트와 동일 구조) + const selectedPanelComponentId = externalSelectedPanelComponentId || null; + const rafRef = useRef(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(); // 추가 모달 상태 @@ -2079,8 +2292,191 @@ export const SplitPanelLayoutComponent: React.FC
)} - {/* 좌측 데이터 목록/테이블 */} - {componentConfig.leftPanel?.displayMode === "table" ? ( + {/* 좌측 데이터 목록/테이블/커스텀 */} + {componentConfig.leftPanel?.displayMode === "custom" ? ( + // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 +
+ {/* 🆕 커스텀 모드: 디자인/실행 모드 통합 렌더링 */} + {componentConfig.leftPanel?.components && componentConfig.leftPanel.components.length > 0 ? ( +
+ {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 ( +
{ + e.stopPropagation(); + onSelectPanelComponent?.("left", comp.id, comp); + }} + > + {/* 드래그 핸들 - 컴포넌트 외부 상단 */} +
handlePanelDragStart(e, "left", comp)} + > +
+ + + {comp.label || comp.componentType} + +
+
+ + +
+
+ + {/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */} +
+
+ +
+ + {/* 리사이즈 가장자리 영역 - 선택된 컴포넌트에만 표시 */} + {isSelectedComp && ( + <> + {/* 오른쪽 가장자리 (너비 조절) */} +
handlePanelResizeStart(e, "left", comp, "e")} + /> + {/* 아래 가장자리 (높이 조절) */} +
handlePanelResizeStart(e, "left", comp, "s")} + /> + {/* 오른쪽 아래 모서리 (너비+높이 조절) */} +
handlePanelResizeStart(e, "left", comp, "se")} + /> + + )} +
+
+ ); + } 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 ( +
+ +
+ ); + } + })} +
+ ) : ( + // 컴포넌트가 없을 때 드롭 영역 표시 +
+ +

+ 커스텀 모드 +

+

+ {isDesignMode ? "컴포넌트를 드래그하여 배치하세요" : "배치된 컴포넌트가 없습니다"} +

+
+ )} +
+ ) : componentConfig.leftPanel?.displayMode === "table" ? ( // 테이블 모드
{isDesignMode ? ( @@ -2577,8 +2973,180 @@ export const SplitPanelLayoutComponent: React.FC
)} - {/* 우측 데이터 */} - {isLoadingRight ? ( + {/* 우측 데이터/커스텀 */} + {componentConfig.rightPanel?.displayMode === "custom" ? ( + // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 +
+ {/* 🆕 커스텀 모드: 디자인/실행 모드 통합 렌더링 */} + {componentConfig.rightPanel?.components && componentConfig.rightPanel.components.length > 0 ? ( +
+ {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 ( +
{ + e.stopPropagation(); + onSelectPanelComponent?.("right", comp.id, comp); + }} + > + {/* 드래그 핸들 - 컴포넌트 외부 상단 */} +
handlePanelDragStart(e, "right", comp)} + > +
+ + + {comp.label || comp.componentType} + +
+
+ + +
+
+ + {/* 실제 컴포넌트 렌더링 - 핸들 아래에 별도 영역 */} +
+
+ +
+ + {/* 리사이즈 가장자리 영역 - 선택된 컴포넌트에만 표시 */} + {isSelectedComp && ( + <> + {/* 오른쪽 가장자리 (너비 조절) */} +
handlePanelResizeStart(e, "right", comp, "e")} + /> + {/* 아래 가장자리 (높이 조절) */} +
handlePanelResizeStart(e, "right", comp, "s")} + /> + {/* 오른쪽 아래 모서리 (너비+높이 조절) */} +
handlePanelResizeStart(e, "right", comp, "se")} + /> + + )} +
+
+ ); + } else { + + return ( +
+ +
+ ); + } + })} +
+ ) : ( + // 컴포넌트가 없을 때 드롭 영역 표시 +
+ +

+ 커스텀 모드 +

+

+ {isDesignMode ? "컴포넌트를 드래그하여 배치하세요" : "배치된 컴포넌트가 없습니다"} +

+
+ )} +
+ ) : isLoadingRight ? ( // 로딩 중
diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx index d678332e..79813d8f 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -11,7 +11,8 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; // 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 { SplitPanelLayoutConfig, AdditionalTabConfig } from "./types"; import { TableInfo, ColumnInfo } from "@/types/screen"; @@ -1547,7 +1548,7 @@ export const SplitPanelLayoutConfigPanel: React.FC표시 모드 + {config.leftPanel?.displayMode === "custom" && ( +

+ 화면 디자이너에서 좌측 패널에 컴포넌트를 드래그하여 배치하세요. +

+ )}
- {/* 좌측 패널 표시 컬럼 설정 - 체크박스 방식 */} + {/* 🆕 커스텀 모드: 배치된 컴포넌트 목록 */} + {config.leftPanel?.displayMode === "custom" && ( +
+ + {!config.leftPanel?.components || config.leftPanel.components.length === 0 ? ( +
+ +

+ 디자인 화면에서 컴포넌트를 드래그하여 추가하세요 +

+
+ ) : ( +
+ {config.leftPanel.components.map((comp: PanelInlineComponent) => ( +
+
+

+ {comp.label || comp.componentType} +

+

+ {comp.componentType} | 위치: ({comp.position?.x || 0}, {comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x{comp.size?.height || 0} +

+
+ +
+ ))} +
+ )} +
+ )} + + {/* 좌측 패널 표시 컬럼 설정 - 체크박스 방식 (커스텀 모드가 아닐 때만) */} + {config.leftPanel?.displayMode !== "custom" && (
@@ -1731,6 +1796,7 @@ export const SplitPanelLayoutConfigPanel: React.FC )}
+ )}
{/* 좌측 패널 데이터 필터링 */} @@ -1851,7 +1917,7 @@ export const SplitPanelLayoutConfigPanel: React.FC표시 모드 + {config.rightPanel?.displayMode === "custom" && ( +

+ 화면 디자이너에서 우측 패널에 컴포넌트를 드래그하여 배치하세요. +

+ )}
- {/* 요약 표시 설정 (LIST 모드에서만) */} + {/* 🆕 커스텀 모드: 배치된 컴포넌트 목록 */} + {config.rightPanel?.displayMode === "custom" && ( +
+ + {!config.rightPanel?.components || config.rightPanel.components.length === 0 ? ( +
+ +

+ 디자인 화면에서 컴포넌트를 드래그하여 추가하세요 +

+
+ ) : ( +
+ {config.rightPanel.components.map((comp: PanelInlineComponent) => ( +
+
+

+ {comp.label || comp.componentType} +

+

+ {comp.componentType} | 위치: ({comp.position?.x || 0}, {comp.position?.y || 0}) | 크기: {comp.size?.width || 0}x{comp.size?.height || 0} +

+
+ +
+ ))} +
+ )} +
+ )} + + {/* 요약 표시 설정 (LIST 모드에서만, 커스텀 모드가 아닐 때) */} {(config.rightPanel?.displayMode || "list") === "list" && (
diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index 123dc13a..7ab0dbcb 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -2,7 +2,13 @@ * SplitPanelLayout 컴포넌트 타입 정의 */ -import { DataFilterConfig } from "@/types/screen-management"; +import { DataFilterConfig, TabInlineComponent } from "@/types/screen-management"; + +/** + * 패널 내 인라인 컴포넌트 (커스텀 모드용) + * TabInlineComponent와 동일한 구조 사용 + */ +export type PanelInlineComponent = TabInlineComponent; /** * 추가 탭 설정 (우측 패널과 동일한 구조 + tabId, label) @@ -118,7 +124,9 @@ export interface SplitPanelLayoutConfig { useCustomTable?: boolean; // 화면 기본 테이블이 아닌 다른 테이블 사용 여부 customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때) dataSource?: string; // API 엔드포인트 - displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블 + displayMode?: "list" | "table" | "custom"; // 표시 모드: 목록, 테이블, 또는 커스텀 + // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 (탭 컴포넌트와 동일 구조) + components?: PanelInlineComponent[]; showSearch?: boolean; showAdd?: boolean; showEdit?: boolean; // 수정 버튼 @@ -185,7 +193,9 @@ export interface SplitPanelLayoutConfig { useCustomTable?: boolean; // 화면 기본 테이블이 아닌 다른 테이블 사용 여부 customTableName?: string; // 사용자 지정 테이블명 (useCustomTable이 true일 때) dataSource?: string; - displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블 + displayMode?: "list" | "table" | "custom"; // 표시 모드: 목록, 테이블, 또는 커스텀 + // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 (탭 컴포넌트와 동일 구조) + components?: PanelInlineComponent[]; showSearch?: boolean; showAdd?: boolean; showEdit?: boolean; // 수정 버튼 diff --git a/frontend/lib/registry/components/v2-table-grouped/README.md b/frontend/lib/registry/components/v2-table-grouped/README.md new file mode 100644 index 00000000..fc39733e --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/README.md @@ -0,0 +1,162 @@ +# v2-table-grouped (그룹화 테이블) + +## 개요 + +데이터를 특정 컬럼 기준으로 그룹화하여 접기/펼치기 기능을 제공하는 테이블 컴포넌트입니다. + +## 주요 기능 + +- **그룹화**: 지정된 컬럼 기준으로 데이터를 그룹핑 +- **접기/펼치기**: 그룹 헤더 클릭으로 하위 항목 토글 +- **그룹 요약**: 그룹별 개수, 합계, 평균, 최대/최소값 표시 +- **체크박스 선택**: 그룹 단위 또는 개별 항목 선택 +- **전체 펼치기/접기**: 모든 그룹 일괄 토글 버튼 + +## 사용 예시 + +```tsx +import { TableGroupedComponent } from "./TableGroupedComponent"; + + 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`: 마스터-디테일 레이아웃 diff --git a/frontend/lib/registry/components/v2-table-grouped/TableGroupedComponent.tsx b/frontend/lib/registry/components/v2-table-grouped/TableGroupedComponent.tsx new file mode 100644 index 00000000..5d33187a --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/TableGroupedComponent.tsx @@ -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>({}); + + // 필터 및 그룹 설정 상태 (검색필터 연동용) + const [filters, setFilters] = useState([]); + const [grouping, setGrouping] = useState([]); + const [columnVisibility, setColumnVisibility] = useState([]); + + // 그룹화 데이터 훅 (검색 필터 전달) + 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 = {}; + 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(); + 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 = {}; + 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 ( +
+
+ + 그룹화 테이블 + {config.groupConfig?.groupByColumn && ( + + (그룹: {config.groupConfig.groupByColumn}) + + )} +
+
+ 테이블: {config.useCustomTable ? config.customTableName : config.selectedTable || "(미설정)"} +
+
+ ); + } + + // 로딩 상태 + if (isLoading) { + return ( +
+ + 로딩 중... +
+ ); + } + + // 에러 상태 + if (error) { + return ( +
+ {error} +
+ ); + } + + // 데이터 없음 + if (groups.length === 0) { + return ( +
+ {config.emptyMessage || "데이터가 없습니다."} +
+ ); + } + + return ( +
+ {/* 툴바 */} + {config.showExpandAllButton && ( +
+
+ + +
+
+ {groupCount}개 그룹 | 총 {totalCount}건 +
+
+ )} + + {/* 테이블 */} +
+ + {/* 테이블 헤더 */} + + + {/* 전체 선택 체크박스 */} + {config.showCheckbox && ( + + )} + {/* 컬럼 헤더 */} + {visibleColumns.map((col) => ( + + ))} + + + + {/* 테이블 바디 */} + + {groups.map((group) => ( + + {/* 그룹 헤더 */} + 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 ( + handleRowClick(item, group.groupKey, idx)} + > + {/* 체크박스 */} + {config.showCheckbox && ( + + )} + + {/* 데이터 컬럼 */} + {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 ( + + ); + })} + + ); + })} + + ))} + +
+ + + {col.displayName || col.columnName} +
e.stopPropagation()} + > + + toggleItemSelection(group.groupKey, itemId) + } + /> + + {displayValue ?? "-"} +
+
+
+ ); +} + +export default TableGroupedComponent; diff --git a/frontend/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel.tsx b/frontend/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel.tsx new file mode 100644 index 00000000..beb0f5b6 --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/TableGroupedConfigPanel.tsx @@ -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) => void; +} + +/** + * v2-table-grouped 설정 패널 + */ +// 테이블 정보 타입 +interface TableInfo { + tableName: string; + displayName: string; +} + +export function TableGroupedConfigPanel({ + config, + onChange, +}: TableGroupedConfigPanelProps) { + // 테이블 목록 (라벨명 포함) + const [tables, setTables] = useState([]); + const [tableColumns, setTableColumns] = useState([]); + 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) => { + onChange({ ...config, ...updates }); + }; + + // 그룹 설정 업데이트 헬퍼 + const updateGroupConfig = ( + updates: Partial + ) => { + 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 + ) => { + const filters = [...(config.linkedFilters || [])]; + filters[index] = { ...filters[index], ...updates }; + updateConfig({ linkedFilters: filters }); + }; + + return ( +
+ + {/* 테이블 설정 */} + + + 테이블 설정 + + + {/* 커스텀 테이블 사용 */} +
+ + + updateConfig({ useCustomTable: checked }) + } + /> +
+ + {/* 테이블 선택 */} + {config.useCustomTable ? ( +
+ + + updateConfig({ customTableName: e.target.value }) + } + placeholder="테이블명 입력" + className="h-8 text-xs" + /> +
+ ) : ( +
+ + + + + + + { + // 테이블명 또는 라벨명에 검색어가 포함되면 1, 아니면 0 + const lowerSearch = search.toLowerCase(); + if (value.toLowerCase().includes(lowerSearch)) { + return 1; + } + return 0; + }} + > + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + updateConfig({ selectedTable: table.tableName }); + setTableSelectOpen(false); + }} + className="text-xs" + > + +
+ {table.displayName} + + {table.tableName} + +
+
+ ))} +
+
+
+
+
+
+ )} +
+
+ + {/* 그룹화 설정 */} + + + 그룹화 설정 + + + {/* 그룹화 기준 컬럼 */} +
+ + +
+ + {/* 그룹 라벨 형식 */} +
+ + + updateGroupConfig({ groupLabelFormat: e.target.value }) + } + placeholder="{value} ({컬럼명})" + className="h-8 text-xs" + /> +

+ {"{value}"} = 그룹값, {"{컬럼명}"} = 해당 컬럼 값 +

+
+ + {/* 기본 펼침 상태 */} +
+ + + updateGroupConfig({ defaultExpanded: checked }) + } + /> +
+ + {/* 그룹 정렬 */} +
+ + +
+ + {/* 개수 표시 */} +
+ + + updateGroupConfig({ + summary: { + ...config.groupConfig?.summary, + showCount: checked, + }, + }) + } + /> +
+ + {/* 합계 컬럼 */} +
+ +
+ {tableColumns.map((col) => ( +
+ toggleSumColumn(col.columnName)} + /> + +
+ ))} +
+
+
+
+ + {/* 표시 설정 */} + + + 표시 설정 + + + {/* 체크박스 표시 */} +
+ + + updateConfig({ showCheckbox: checked }) + } + /> +
+ + {/* 체크박스 모드 */} + {config.showCheckbox && ( +
+ + +
+ )} + + {/* 그룹 헤더 스타일 */} +
+ + +
+ + {/* 전체 펼치기/접기 버튼 */} +
+ + + updateConfig({ showExpandAllButton: checked }) + } + /> +
+ + {/* 행 클릭 가능 */} +
+ + + updateConfig({ rowClickable: checked }) + } + /> +
+ + {/* 최대 높이 */} +
+ + + updateConfig({ maxHeight: parseInt(e.target.value) || 600 }) + } + className="h-8 text-xs" + /> +
+ + {/* 빈 데이터 메시지 */} +
+ + + updateConfig({ emptyMessage: e.target.value }) + } + placeholder="데이터가 없습니다." + className="h-8 text-xs" + /> +
+
+
+ + {/* 컬럼 설정 */} + + + 컬럼 설정 + + +
+ {(config.columns || tableColumns).map((col) => ( +
+ + toggleColumnVisibility(col.columnName) + } + /> + +
+ ))} +
+
+
+ + {/* 연동 설정 */} + + + 연동 설정 + + +
+
+ + +
+ + {(config.linkedFilters || []).length === 0 ? ( +

+ 연결된 필터가 없습니다. +

+ ) : ( +
+ {(config.linkedFilters || []).map((filter, idx) => ( +
+
+ + 필터 #{idx + 1} + +
+ + updateLinkedFilter(idx, { enabled: checked }) + } + /> + +
+
+ +
+ + + updateLinkedFilter(idx, { + sourceComponentId: e.target.value, + }) + } + placeholder="예: search-filter-1" + className="h-7 text-xs" + /> +
+ +
+ + + updateLinkedFilter(idx, { + sourceField: e.target.value, + }) + } + placeholder="value" + className="h-7 text-xs" + /> +
+ +
+ + +
+
+ ))} +
+ )} + +

+ 다른 컴포넌트(검색필터 등)의 선택 값으로 이 테이블을 필터링합니다. +

+
+
+
+
+
+ ); +} + +export default TableGroupedConfigPanel; diff --git a/frontend/lib/registry/components/v2-table-grouped/TableGroupedRenderer.tsx b/frontend/lib/registry/components/v2-table-grouped/TableGroupedRenderer.tsx new file mode 100644 index 00000000..245d8ee6 --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/TableGroupedRenderer.tsx @@ -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 ( + + ); + } + + // 설정 변경 핸들러 + 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); +} diff --git a/frontend/lib/registry/components/v2-table-grouped/components/GroupHeader.tsx b/frontend/lib/registry/components/v2-table-grouped/components/GroupHeader.tsx new file mode 100644 index 00000000..f7119f4e --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/components/GroupHeader.tsx @@ -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 ( + + { + // 체크박스 클릭 시 토글 방지 + if ((e.target as HTMLElement).closest('[data-checkbox="true"]')) { + return; + } + onToggle(); + }} + > +
+ {/* 펼치기/접기 아이콘 */} + + {group.expanded ? ( + + ) : ( + + )} + + + {/* 체크박스 */} + {showCheckbox && onSelectToggle && ( + { + e.stopPropagation(); + onSelectToggle(); + }} + > + + {isIndeterminate && ( + + )} + + )} + + {/* 그룹 라벨 */} + {group.groupLabel} + + {/* 요약 정보 */} + {summaryText && ( + + {summaryText} + + )} +
+ + + ); +} + +export default GroupHeader; diff --git a/frontend/lib/registry/components/v2-table-grouped/config.ts b/frontend/lib/registry/components/v2-table-grouped/config.ts new file mode 100644 index 00000000..fb38744c --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/config.ts @@ -0,0 +1,64 @@ +import { TableGroupedConfig } from "./types"; + +/** + * v2-table-grouped 기본 설정값 + */ +export const defaultTableGroupedConfig: Partial = { + // 그룹화 기본 설정 + 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: "내림차순" }, +]; diff --git a/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts b/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts new file mode 100644 index 00000000..d9f40aca --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/hooks/useGroupedData.ts @@ -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 +): UseGroupedDataResult { + // 원본 데이터 + const [rawData, setRawData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // 그룹 펼침 상태 관리 + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + // 사용자가 수동으로 펼침/접기를 조작했는지 여부 + const [isManuallyControlled, setIsManuallyControlled] = useState(false); + + // 선택 상태 관리 + const [selectedItemIds, setSelectedItemIds] = useState>( + 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(); + + 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; diff --git a/frontend/lib/registry/components/v2-table-grouped/index.ts b/frontend/lib/registry/components/v2-table-grouped/index.ts new file mode 100644 index 00000000..7e984490 --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/index.ts @@ -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"; diff --git a/frontend/lib/registry/components/v2-table-grouped/types.ts b/frontend/lib/registry/components/v2-table-grouped/types.ts new file mode 100644 index 00000000..20bfc77b --- /dev/null +++ b/frontend/lib/registry/components/v2-table-grouped/types.ts @@ -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; + /** 평균 (컬럼별) */ + avg?: Record; + /** 최대값 (컬럼별) */ + max?: Record; + /** 최소값 (컬럼별) */ + min?: Record; +} + +/** + * 그룹 상태 + */ +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; + + /** 선택 변경 이벤트 */ + 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; +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/README.md b/frontend/lib/registry/components/v2-timeline-scheduler/README.md new file mode 100644 index 00000000..2e8d7262 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/README.md @@ -0,0 +1,159 @@ +# v2-timeline-scheduler + +간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트 + +## 개요 + +`v2-timeline-scheduler`는 생산계획, 일정관리 등에서 사용할 수 있는 타임라인 기반의 스케줄러 컴포넌트입니다. 리소스(설비, 작업자 등)별로 스케줄을 시각화하고, 드래그/리사이즈로 일정을 조정할 수 있습니다. + +## 핵심 기능 + +| 기능 | 설명 | +|------|------| +| 타임라인 그리드 | 일/주/월 단위 그리드 표시 | +| 스케줄 바 | 시작~종료 기간 바 렌더링 | +| 리소스 행 | 설비/작업자별 행 구분 | +| 드래그 이동 | 스케줄 바 드래그로 날짜 변경 | +| 리사이즈 | 바 양쪽 핸들로 기간 조정 | +| 줌 레벨 | 일/주/월 단위 전환 | +| 진행률 표시 | 바 내부 진행률 표시 | +| 오늘 표시선 | 현재 날짜 표시선 | + +## 사용법 + +### 기본 사용 + +```tsx +import { TimelineSchedulerComponent } from "@/lib/registry/components/v2-timeline-scheduler"; + + { + 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 diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx new file mode 100644 index 00000000..23301657 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx @@ -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(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(); + + 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 ( +
+
+ +

타임라인 스케줄러

+

+ {config.selectedTable + ? `테이블: ${config.selectedTable}` + : "테이블을 선택하세요"} +

+
+
+ ); + } + + // 로딩 상태 + if (isLoading) { + return ( +
+
+ + 로딩 중... +
+
+ ); + } + + // 에러 상태 + if (error) { + return ( +
+
+

오류 발생

+

{error}

+
+
+ ); + } + + // 리소스 없음 + if (resources.length === 0) { + return ( +
+
+ +

리소스가 없습니다

+

리소스 테이블을 설정하세요

+
+
+ ); + } + + return ( +
+ {/* 툴바 */} + {config.showToolbar !== false && ( +
+ {/* 네비게이션 */} +
+ {config.showNavigation !== false && ( + <> + + + + + )} + + {/* 현재 날짜 범위 표시 */} + + {viewStartDate.getFullYear()}년 {viewStartDate.getMonth() + 1}월{" "} + {viewStartDate.getDate()}일 ~{" "} + {viewEndDate.getMonth() + 1}월 {viewEndDate.getDate()}일 + +
+ + {/* 오른쪽 컨트롤 */} +
+ {/* 줌 컨트롤 */} + {config.showZoomControls !== false && ( +
+ + + {zoomLevelOptions.find((o) => o.value === zoomLevel)?.label} + + +
+ )} + + {/* 추가 버튼 */} + {config.showAddButton !== false && config.editable && ( + + )} +
+
+ )} + + {/* 타임라인 본문 */} +
+
+ {/* 헤더 */} + + + {/* 리소스 행들 */} +
+ {resources.map((resource) => ( + + ))} +
+
+
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx new file mode 100644 index 00000000..3371d425 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerConfigPanel.tsx @@ -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) => void; +} + +interface TableInfo { + tableName: string; + displayName: string; +} + +interface ColumnInfo { + columnName: string; + displayName: string; +} + +export function TimelineSchedulerConfigPanel({ + config, + onChange, +}: TimelineSchedulerConfigPanelProps) { + const [tables, setTables] = useState([]); + const [tableColumns, setTableColumns] = useState([]); + const [resourceColumns, setResourceColumns] = useState([]); + 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) => { + 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 ( +
+ + {/* 테이블 설정 */} + + + 테이블 설정 + + + {/* 스케줄 테이블 선택 */} +
+ + + + + + + { + const lowerSearch = search.toLowerCase(); + if (value.toLowerCase().includes(lowerSearch)) { + return 1; + } + return 0; + }} + > + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + updateConfig({ selectedTable: table.tableName }); + setTableSelectOpen(false); + }} + className="text-xs" + > + +
+ {table.displayName} + + {table.tableName} + +
+
+ ))} +
+
+
+
+
+
+ + {/* 리소스 테이블 선택 */} +
+ + + + + + + { + const lowerSearch = search.toLowerCase(); + if (value.toLowerCase().includes(lowerSearch)) { + return 1; + } + return 0; + }} + > + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + updateConfig({ resourceTable: table.tableName }); + setResourceTableSelectOpen(false); + }} + className="text-xs" + > + +
+ {table.displayName} + + {table.tableName} + +
+
+ ))} +
+
+
+
+
+
+
+
+ + {/* 필드 매핑 */} + + + 필드 매핑 + + + {/* 스케줄 필드 매핑 */} + {config.selectedTable && ( +
+ +
+ {/* ID 필드 */} +
+ + +
+ + {/* 리소스 ID 필드 */} +
+ + +
+ + {/* 제목 필드 */} +
+ + +
+ + {/* 시작일 필드 */} +
+ + +
+ + {/* 종료일 필드 */} +
+ + +
+ + {/* 상태 필드 */} +
+ + +
+
+
+ )} + + {/* 리소스 필드 매핑 */} + {config.resourceTable && ( +
+ +
+ {/* ID 필드 */} +
+ + +
+ + {/* 이름 필드 */} +
+ + +
+
+
+ )} +
+
+ + {/* 표시 설정 */} + + + 표시 설정 + + + {/* 기본 줌 레벨 */} +
+ + +
+ + {/* 높이 */} +
+ + + updateConfig({ height: parseInt(e.target.value) || 500 }) + } + className="h-8 text-xs" + /> +
+ + {/* 행 높이 */} +
+ + + updateConfig({ rowHeight: parseInt(e.target.value) || 50 }) + } + className="h-8 text-xs" + /> +
+ + {/* 토글 스위치들 */} +
+
+ + updateConfig({ editable: v })} + /> +
+ +
+ + updateConfig({ draggable: v })} + /> +
+ +
+ + updateConfig({ resizable: v })} + /> +
+ +
+ + updateConfig({ showTodayLine: v })} + /> +
+ +
+ + updateConfig({ showProgress: v })} + /> +
+ +
+ + updateConfig({ showToolbar: v })} + /> +
+
+
+
+
+
+ ); +} + +export default TimelineSchedulerConfigPanel; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerRenderer.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerRenderer.tsx new file mode 100644 index 00000000..48e8a21f --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerRenderer.tsx @@ -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 ( + + ); + } + + // 설정 변경 핸들러 + 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); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx new file mode 100644 index 00000000..407bdd14 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx @@ -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) => { + 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 ( +
+ {/* 리소스 컬럼 */} +
+
+
{resource.name}
+ {resource.group && ( +
+ {resource.group} +
+ )} +
+
+ + {/* 타임라인 그리드 */} +
+ {/* 배경 그리드 */} +
+ {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 ( +
+ ); + })} +
+ + {/* 스케줄 바들 */} + {schedulePositions.map(({ schedule, position }) => ( + onScheduleClick?.(schedule)} + onDragStart={onDragStart} + onDragEnd={onDragEnd} + onResizeStart={onResizeStart} + onResizeEnd={onResizeEnd} + /> + ))} +
+
+ ); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx new file mode 100644 index 00000000..a85c457c --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx @@ -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(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 ( +
+ {/* 진행률 바 */} + {config.showProgress && schedule.progress !== undefined && ( +
+ )} + + {/* 제목 */} +
+ {schedule.title} +
+ + {/* 진행률 텍스트 */} + {config.showProgress && schedule.progress !== undefined && ( +
+ {schedule.progress}% +
+ )} + + {/* 리사이즈 핸들 - 왼쪽 */} + {resizable && ( +
handleResizeStart("start", e)} + /> + )} + + {/* 리사이즈 핸들 - 오른쪽 */} + {resizable && ( +
handleResizeStart("end", e)} + /> + )} +
+ ); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineHeader.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineHeader.tsx new file mode 100644 index 00000000..52afc2e2 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineHeader.tsx @@ -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 ( +
+ {/* 상단 행: 월/년도 */} +
+ {/* 리소스 컬럼 헤더 */} +
+ 리소스 +
+ + {/* 월 그룹 */} + {monthGroups.map((group, idx) => ( +
+ {group.year}년 {group.month} +
+ ))} +
+ + {/* 하단 행: 일자 */} +
+ {/* 리소스 컬럼 (빈칸) */} +
+ + {/* 날짜 셀 */} + {dateCells.map((cell, idx) => ( +
+ {cell.label} +
+ ))} +
+ + {/* 오늘 표시선 */} + {showTodayLine && todayPosition !== null && ( +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts b/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts new file mode 100644 index 00000000..4da03f17 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts @@ -0,0 +1,3 @@ +export { TimelineHeader } from "./TimelineHeader"; +export { ScheduleBar } from "./ScheduleBar"; +export { ResourceRow } from "./ResourceRow"; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/config.ts b/frontend/lib/registry/components/v2-timeline-scheduler/config.ts new file mode 100644 index 00000000..f8b10f94 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/config.ts @@ -0,0 +1,102 @@ +"use client"; + +import { TimelineSchedulerConfig, ZoomLevel } from "./types"; + +/** + * 기본 타임라인 스케줄러 설정 + */ +export const defaultTimelineSchedulerConfig: Partial = { + 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 = { + 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월", +]; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts b/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts new file mode 100644 index 00000000..61504c3d --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/hooks/useTimelineData.ts @@ -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([]); + const [resources, setResources] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [zoomLevel, setZoomLevel] = useState( + config.defaultZoomLevel || "day" + ); + const [viewStartDate, setViewStartDate] = useState(() => { + 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) => { + if (!tableName || !config.editable) return; + + try { + // 필드 매핑 역변환 + const updateData: Record = {}; + 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) => { + if (!tableName || !config.editable) return; + + try { + // 필드 매핑 역변환 + const insertData: Record = { + [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, + }; +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/index.ts b/frontend/lib/registry/components/v2-timeline-scheduler/index.ts new file mode 100644 index 00000000..33c483a0 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/index.ts @@ -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"; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts new file mode 100644 index 00000000..eba6f4e3 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts @@ -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; +} + +/** + * 리소스 (행 - 설비/작업자) + */ +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; + + /** 외부 스케줄 데이터 */ + 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) => Promise; + + /** 스케줄 추가 */ + addSchedule: (schedule: Omit) => Promise; + + /** 스케줄 삭제 */ + deleteSchedule: (id: string) => Promise; + + /** 데이터 새로고침 */ + 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; +} diff --git a/frontend/lib/utils/layoutV2Converter.ts b/frontend/lib/utils/layoutV2Converter.ts index d1d7487d..2c65189e 100644 --- a/frontend/lib/utils/layoutV2Converter.ts +++ b/frontend/lib/utils/layoutV2Converter.ts @@ -66,6 +66,8 @@ export function convertV2ToLegacy(v2Layout: LayoutV2 | null): LegacyLayoutData | codeCategory: overrides.codeCategory, inputType: overrides.inputType, webType: overrides.webType, + // 🆕 autoFill 설정 복원 (자동 입력 기능) + autoFill: overrides.autoFill, // 기존 구조 호환을 위한 추가 필드 style: {}, parentId: null, @@ -115,6 +117,8 @@ export function convertLegacyToV2(legacyLayout: LegacyLayoutData): LayoutV2 { if (comp.codeCategory) topLevelProps.codeCategory = comp.codeCategory; if (comp.inputType) topLevelProps.inputType = comp.inputType; if (comp.webType) topLevelProps.webType = comp.webType; + // 🆕 autoFill 설정 저장 (자동 입력 기능) + if (comp.autoFill) topLevelProps.autoFill = comp.autoFill; // 현재 설정에서 차이값만 추출 const fullConfig = comp.componentConfig || {};