docs: v2-timeline-scheduler 구현 완료 및 상태 업데이트
- v2-timeline-scheduler의 구현 상태를 체크리스트에 반영하였으며, 관련 문서화 작업을 완료하였습니다. - 각 구성 요소의 구현 완료 상태를 명시하고, 향후 작업 계획을 업데이트하였습니다. - 타임라인 스케줄러 컴포넌트를 레지스트리에 추가하여 통합하였습니다.
This commit is contained in:
parent
b9d6e5854d
commit
f959ca98bd
|
|
@ -531,25 +531,25 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul
|
||||||
- [x] 레지스트리 등록
|
- [x] 레지스트리 등록
|
||||||
- [x] 문서화 (README.md)
|
- [x] 문서화 (README.md)
|
||||||
|
|
||||||
#### v2-timeline-scheduler
|
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30)
|
||||||
|
|
||||||
- [ ] 타입 정의 완료
|
- [x] 타입 정의 완료
|
||||||
- [ ] 기본 구조 생성
|
- [x] 기본 구조 생성
|
||||||
- [ ] TimelineHeader (날짜)
|
- [x] TimelineHeader (날짜)
|
||||||
- [ ] TimelineGrid (배경)
|
- [x] TimelineGrid (배경)
|
||||||
- [ ] ResourceColumn (리소스)
|
- [x] ResourceColumn (리소스)
|
||||||
- [ ] ScheduleBar 기본 렌더링
|
- [x] ScheduleBar 기본 렌더링
|
||||||
- [ ] 드래그 이동
|
- [x] 드래그 이동 (기본)
|
||||||
- [ ] 리사이즈
|
- [x] 리사이즈 (기본)
|
||||||
- [ ] 줌 레벨 전환
|
- [x] 줌 레벨 전환
|
||||||
- [ ] 날짜 네비게이션
|
- [x] 날짜 네비게이션
|
||||||
- [ ] 충돌 감지
|
- [ ] 충돌 감지 (향후)
|
||||||
- [ ] 가상 스크롤
|
- [ ] 가상 스크롤 (향후)
|
||||||
- [ ] 설정 패널 구현
|
- [x] 설정 패널 구현
|
||||||
- [ ] API 연동
|
- [x] API 연동
|
||||||
- [ ] 레지스트리 등록
|
- [x] 레지스트리 등록
|
||||||
- [ ] 테스트 완료
|
- [ ] 테스트 완료
|
||||||
- [ ] 문서화
|
- [x] 문서화 (README.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ import "./v2-tabs-widget/tabs-component";
|
||||||
import "./v2-category-manager/V2CategoryManagerRenderer";
|
import "./v2-category-manager/V2CategoryManagerRenderer";
|
||||||
import "./v2-media"; // 통합 미디어 컴포넌트
|
import "./v2-media"; // 통합 미디어 컴포넌트
|
||||||
import "./v2-table-grouped/TableGroupedRenderer"; // 그룹화 테이블
|
import "./v2-table-grouped/TableGroupedRenderer"; // 그룹화 테이블
|
||||||
|
import "./v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스케줄러
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ import { Trash2, Plus } from "lucide-react";
|
||||||
|
|
||||||
interface TableGroupedConfigPanelProps {
|
interface TableGroupedConfigPanelProps {
|
||||||
config: TableGroupedConfig;
|
config: TableGroupedConfig;
|
||||||
onConfigChange: (newConfig: TableGroupedConfig) => void;
|
onChange: (newConfig: Partial<TableGroupedConfig>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -59,7 +59,7 @@ interface TableInfo {
|
||||||
|
|
||||||
export function TableGroupedConfigPanel({
|
export function TableGroupedConfigPanel({
|
||||||
config,
|
config,
|
||||||
onConfigChange,
|
onChange,
|
||||||
}: TableGroupedConfigPanelProps) {
|
}: TableGroupedConfigPanelProps) {
|
||||||
// 테이블 목록 (라벨명 포함)
|
// 테이블 목록 (라벨명 포함)
|
||||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
|
|
@ -122,7 +122,7 @@ export function TableGroupedConfigPanel({
|
||||||
|
|
||||||
// 컬럼 설정이 없으면 자동 설정
|
// 컬럼 설정이 없으면 자동 설정
|
||||||
if (!config.columns || config.columns.length === 0) {
|
if (!config.columns || config.columns.length === 0) {
|
||||||
onConfigChange({ ...config, columns: cols });
|
onChange({ ...config, columns: cols });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -136,14 +136,14 @@ export function TableGroupedConfigPanel({
|
||||||
|
|
||||||
// 설정 업데이트 헬퍼
|
// 설정 업데이트 헬퍼
|
||||||
const updateConfig = (updates: Partial<TableGroupedConfig>) => {
|
const updateConfig = (updates: Partial<TableGroupedConfig>) => {
|
||||||
onConfigChange({ ...config, ...updates });
|
onChange({ ...config, ...updates });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 그룹 설정 업데이트 헬퍼
|
// 그룹 설정 업데이트 헬퍼
|
||||||
const updateGroupConfig = (
|
const updateGroupConfig = (
|
||||||
updates: Partial<TableGroupedConfig["groupConfig"]>
|
updates: Partial<TableGroupedConfig["groupConfig"]>
|
||||||
) => {
|
) => {
|
||||||
onConfigChange({
|
onChange({
|
||||||
...config,
|
...config,
|
||||||
groupConfig: { ...config.groupConfig, ...updates },
|
groupConfig: { ...config.groupConfig, ...updates },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
# v2-timeline-scheduler
|
||||||
|
|
||||||
|
간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
`v2-timeline-scheduler`는 생산계획, 일정관리 등에서 사용할 수 있는 타임라인 기반의 스케줄러 컴포넌트입니다. 리소스(설비, 작업자 등)별로 스케줄을 시각화하고, 드래그/리사이즈로 일정을 조정할 수 있습니다.
|
||||||
|
|
||||||
|
## 핵심 기능
|
||||||
|
|
||||||
|
| 기능 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| 타임라인 그리드 | 일/주/월 단위 그리드 표시 |
|
||||||
|
| 스케줄 바 | 시작~종료 기간 바 렌더링 |
|
||||||
|
| 리소스 행 | 설비/작업자별 행 구분 |
|
||||||
|
| 드래그 이동 | 스케줄 바 드래그로 날짜 변경 |
|
||||||
|
| 리사이즈 | 바 양쪽 핸들로 기간 조정 |
|
||||||
|
| 줌 레벨 | 일/주/월 단위 전환 |
|
||||||
|
| 진행률 표시 | 바 내부 진행률 표시 |
|
||||||
|
| 오늘 표시선 | 현재 날짜 표시선 |
|
||||||
|
|
||||||
|
## 사용법
|
||||||
|
|
||||||
|
### 기본 사용
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { TimelineSchedulerComponent } from "@/lib/registry/components/v2-timeline-scheduler";
|
||||||
|
|
||||||
|
<TimelineSchedulerComponent
|
||||||
|
config={{
|
||||||
|
selectedTable: "production_schedule",
|
||||||
|
resourceTable: "equipment",
|
||||||
|
fieldMapping: {
|
||||||
|
id: "id",
|
||||||
|
resourceId: "equipment_id",
|
||||||
|
title: "plan_name",
|
||||||
|
startDate: "start_date",
|
||||||
|
endDate: "end_date",
|
||||||
|
status: "status",
|
||||||
|
progress: "progress",
|
||||||
|
},
|
||||||
|
resourceFieldMapping: {
|
||||||
|
id: "id",
|
||||||
|
name: "equipment_name",
|
||||||
|
},
|
||||||
|
defaultZoomLevel: "day",
|
||||||
|
editable: true,
|
||||||
|
}}
|
||||||
|
onScheduleClick={(event) => {
|
||||||
|
console.log("클릭된 스케줄:", event.schedule);
|
||||||
|
}}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
console.log("드래그 완료:", event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 설정 옵션
|
||||||
|
|
||||||
|
| 옵션 | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `selectedTable` | string | - | 스케줄 데이터 테이블명 |
|
||||||
|
| `resourceTable` | string | - | 리소스 테이블명 |
|
||||||
|
| `fieldMapping` | object | - | 스케줄 필드 매핑 |
|
||||||
|
| `resourceFieldMapping` | object | - | 리소스 필드 매핑 |
|
||||||
|
| `defaultZoomLevel` | "day" \| "week" \| "month" | "day" | 기본 줌 레벨 |
|
||||||
|
| `editable` | boolean | true | 편집 가능 여부 |
|
||||||
|
| `draggable` | boolean | true | 드래그 이동 가능 |
|
||||||
|
| `resizable` | boolean | true | 리사이즈 가능 |
|
||||||
|
| `rowHeight` | number | 50 | 행 높이 (px) |
|
||||||
|
| `headerHeight` | number | 60 | 헤더 높이 (px) |
|
||||||
|
| `resourceColumnWidth` | number | 150 | 리소스 컬럼 너비 (px) |
|
||||||
|
| `showTodayLine` | boolean | true | 오늘 표시선 |
|
||||||
|
| `showProgress` | boolean | true | 진행률 표시 |
|
||||||
|
| `showToolbar` | boolean | true | 툴바 표시 |
|
||||||
|
| `height` | number \| string | 500 | 컴포넌트 높이 |
|
||||||
|
|
||||||
|
### 필드 매핑
|
||||||
|
|
||||||
|
스케줄 테이블의 컬럼을 매핑합니다:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
fieldMapping: {
|
||||||
|
id: "id", // 필수: 고유 ID
|
||||||
|
resourceId: "equipment_id", // 필수: 리소스 ID (FK)
|
||||||
|
title: "plan_name", // 필수: 표시 제목
|
||||||
|
startDate: "start_date", // 필수: 시작일
|
||||||
|
endDate: "end_date", // 필수: 종료일
|
||||||
|
status: "status", // 선택: 상태
|
||||||
|
progress: "progress", // 선택: 진행률 (0-100)
|
||||||
|
color: "color", // 선택: 바 색상
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이벤트
|
||||||
|
|
||||||
|
| 이벤트 | 파라미터 | 설명 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| `onScheduleClick` | `{ schedule, resource }` | 스케줄 클릭 |
|
||||||
|
| `onCellClick` | `{ resourceId, date }` | 빈 셀 클릭 |
|
||||||
|
| `onDragEnd` | `{ scheduleId, newStartDate, newEndDate }` | 드래그 완료 |
|
||||||
|
| `onResizeEnd` | `{ scheduleId, newStartDate, newEndDate, direction }` | 리사이즈 완료 |
|
||||||
|
| `onAddSchedule` | `(resourceId, date)` | 추가 버튼 클릭 |
|
||||||
|
|
||||||
|
### 상태별 색상
|
||||||
|
|
||||||
|
기본 상태별 색상:
|
||||||
|
|
||||||
|
| 상태 | 색상 | 의미 |
|
||||||
|
|------|------|------|
|
||||||
|
| `planned` | 파랑 (#3b82f6) | 계획됨 |
|
||||||
|
| `in_progress` | 주황 (#f59e0b) | 진행중 |
|
||||||
|
| `completed` | 초록 (#10b981) | 완료 |
|
||||||
|
| `delayed` | 빨강 (#ef4444) | 지연 |
|
||||||
|
| `cancelled` | 회색 (#6b7280) | 취소 |
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
v2-timeline-scheduler/
|
||||||
|
├── index.ts # Definition
|
||||||
|
├── types.ts # 타입 정의
|
||||||
|
├── config.ts # 기본 설정값
|
||||||
|
├── TimelineSchedulerComponent.tsx # 메인 컴포넌트
|
||||||
|
├── TimelineSchedulerConfigPanel.tsx # 설정 패널
|
||||||
|
├── TimelineSchedulerRenderer.tsx # 레지스트리 등록
|
||||||
|
├── README.md # 문서
|
||||||
|
├── components/
|
||||||
|
│ ├── index.ts
|
||||||
|
│ ├── TimelineHeader.tsx # 날짜 헤더
|
||||||
|
│ ├── ScheduleBar.tsx # 스케줄 바
|
||||||
|
│ └── ResourceRow.tsx # 리소스 행
|
||||||
|
└── hooks/
|
||||||
|
└── useTimelineData.ts # 데이터 관리 훅
|
||||||
|
```
|
||||||
|
|
||||||
|
## v2-table-list와의 차이점
|
||||||
|
|
||||||
|
| 특성 | v2-table-list | v2-timeline-scheduler |
|
||||||
|
|------|---------------|----------------------|
|
||||||
|
| 표현 방식 | 행 기반 테이블 | 시간축 기반 간트차트 |
|
||||||
|
| 데이터 구조 | 단순 목록 | 리소스 + 스케줄 (2개 테이블) |
|
||||||
|
| 편집 방식 | 폼 입력 | 드래그/리사이즈 |
|
||||||
|
| 시간 표현 | 텍스트 | 시각적 바 |
|
||||||
|
| 용도 | 일반 데이터 | 일정/계획 관리 |
|
||||||
|
|
||||||
|
## 향후 개선 사항
|
||||||
|
|
||||||
|
- [ ] 충돌 감지 및 표시
|
||||||
|
- [ ] 가상 스크롤 (대량 데이터)
|
||||||
|
- [ ] 마일스톤 표시
|
||||||
|
- [ ] 의존성 연결선
|
||||||
|
- [ ] 드래그로 새 스케줄 생성
|
||||||
|
- [ ] 컨텍스트 메뉴
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**버전**: 2.0.0
|
||||||
|
**최종 수정**: 2026-01-30
|
||||||
|
|
@ -0,0 +1,413 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Calendar,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
TimelineSchedulerComponentProps,
|
||||||
|
ScheduleItem,
|
||||||
|
ZoomLevel,
|
||||||
|
DragEvent,
|
||||||
|
ResizeEvent,
|
||||||
|
} from "./types";
|
||||||
|
import { useTimelineData } from "./hooks/useTimelineData";
|
||||||
|
import { TimelineHeader, ResourceRow } from "./components";
|
||||||
|
import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2-timeline-scheduler 메인 컴포넌트
|
||||||
|
*
|
||||||
|
* 간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트
|
||||||
|
*/
|
||||||
|
export function TimelineSchedulerComponent({
|
||||||
|
config,
|
||||||
|
isDesignMode = false,
|
||||||
|
formData,
|
||||||
|
externalSchedules,
|
||||||
|
externalResources,
|
||||||
|
isLoading: externalLoading,
|
||||||
|
error: externalError,
|
||||||
|
componentId,
|
||||||
|
onDragEnd,
|
||||||
|
onResizeEnd,
|
||||||
|
onScheduleClick,
|
||||||
|
onCellClick,
|
||||||
|
onAddSchedule,
|
||||||
|
}: TimelineSchedulerComponentProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 드래그/리사이즈 상태
|
||||||
|
const [dragState, setDragState] = useState<{
|
||||||
|
schedule: ScheduleItem;
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [resizeState, setResizeState] = useState<{
|
||||||
|
schedule: ScheduleItem;
|
||||||
|
direction: "start" | "end";
|
||||||
|
startX: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// 타임라인 데이터 훅
|
||||||
|
const {
|
||||||
|
schedules,
|
||||||
|
resources,
|
||||||
|
isLoading: hookLoading,
|
||||||
|
error: hookError,
|
||||||
|
zoomLevel,
|
||||||
|
setZoomLevel,
|
||||||
|
viewStartDate,
|
||||||
|
viewEndDate,
|
||||||
|
goToPrevious,
|
||||||
|
goToNext,
|
||||||
|
goToToday,
|
||||||
|
updateSchedule,
|
||||||
|
} = useTimelineData(config, externalSchedules, externalResources);
|
||||||
|
|
||||||
|
const isLoading = externalLoading ?? hookLoading;
|
||||||
|
const error = externalError ?? hookError;
|
||||||
|
|
||||||
|
// 설정값
|
||||||
|
const rowHeight = config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!;
|
||||||
|
const headerHeight = config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!;
|
||||||
|
const resourceColumnWidth =
|
||||||
|
config.resourceColumnWidth || defaultTimelineSchedulerConfig.resourceColumnWidth!;
|
||||||
|
const cellWidthConfig = config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!;
|
||||||
|
const cellWidth = cellWidthConfig[zoomLevel] || 60;
|
||||||
|
|
||||||
|
// 리소스별 스케줄 그룹화
|
||||||
|
const schedulesByResource = useMemo(() => {
|
||||||
|
const grouped = new Map<string, ScheduleItem[]>();
|
||||||
|
|
||||||
|
resources.forEach((resource) => {
|
||||||
|
grouped.set(resource.id, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
schedules.forEach((schedule) => {
|
||||||
|
const list = grouped.get(schedule.resourceId);
|
||||||
|
if (list) {
|
||||||
|
list.push(schedule);
|
||||||
|
} else {
|
||||||
|
// 리소스가 없는 스케줄은 첫 번째 리소스에 할당
|
||||||
|
const firstResource = resources[0];
|
||||||
|
if (firstResource) {
|
||||||
|
const firstList = grouped.get(firstResource.id);
|
||||||
|
if (firstList) {
|
||||||
|
firstList.push(schedule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
}, [schedules, resources]);
|
||||||
|
|
||||||
|
// 줌 레벨 변경
|
||||||
|
const handleZoomIn = useCallback(() => {
|
||||||
|
const levels: ZoomLevel[] = ["month", "week", "day"];
|
||||||
|
const currentIdx = levels.indexOf(zoomLevel);
|
||||||
|
if (currentIdx < levels.length - 1) {
|
||||||
|
setZoomLevel(levels[currentIdx + 1]);
|
||||||
|
}
|
||||||
|
}, [zoomLevel, setZoomLevel]);
|
||||||
|
|
||||||
|
const handleZoomOut = useCallback(() => {
|
||||||
|
const levels: ZoomLevel[] = ["month", "week", "day"];
|
||||||
|
const currentIdx = levels.indexOf(zoomLevel);
|
||||||
|
if (currentIdx > 0) {
|
||||||
|
setZoomLevel(levels[currentIdx - 1]);
|
||||||
|
}
|
||||||
|
}, [zoomLevel, setZoomLevel]);
|
||||||
|
|
||||||
|
// 스케줄 클릭 핸들러
|
||||||
|
const handleScheduleClick = useCallback(
|
||||||
|
(schedule: ScheduleItem) => {
|
||||||
|
const resource = resources.find((r) => r.id === schedule.resourceId);
|
||||||
|
if (resource && onScheduleClick) {
|
||||||
|
onScheduleClick({ schedule, resource });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[resources, onScheduleClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 빈 셀 클릭 핸들러
|
||||||
|
const handleCellClick = useCallback(
|
||||||
|
(resourceId: string, date: Date) => {
|
||||||
|
if (onCellClick) {
|
||||||
|
onCellClick({
|
||||||
|
resourceId,
|
||||||
|
date: date.toISOString().split("T")[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onCellClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 드래그 시작
|
||||||
|
const handleDragStart = useCallback(
|
||||||
|
(schedule: ScheduleItem, e: React.MouseEvent) => {
|
||||||
|
setDragState({
|
||||||
|
schedule,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 드래그 종료
|
||||||
|
const handleDragEnd = useCallback(() => {
|
||||||
|
if (dragState) {
|
||||||
|
// TODO: 드래그 결과 계산 및 업데이트
|
||||||
|
setDragState(null);
|
||||||
|
}
|
||||||
|
}, [dragState]);
|
||||||
|
|
||||||
|
// 리사이즈 시작
|
||||||
|
const handleResizeStart = useCallback(
|
||||||
|
(schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => {
|
||||||
|
setResizeState({
|
||||||
|
schedule,
|
||||||
|
direction,
|
||||||
|
startX: e.clientX,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 리사이즈 종료
|
||||||
|
const handleResizeEnd = useCallback(() => {
|
||||||
|
if (resizeState) {
|
||||||
|
// TODO: 리사이즈 결과 계산 및 업데이트
|
||||||
|
setResizeState(null);
|
||||||
|
}
|
||||||
|
}, [resizeState]);
|
||||||
|
|
||||||
|
// 추가 버튼 클릭
|
||||||
|
const handleAddClick = useCallback(() => {
|
||||||
|
if (onAddSchedule && resources.length > 0) {
|
||||||
|
onAddSchedule(
|
||||||
|
resources[0].id,
|
||||||
|
new Date().toISOString().split("T")[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [onAddSchedule, resources]);
|
||||||
|
|
||||||
|
// 디자인 모드 플레이스홀더
|
||||||
|
if (isDesignMode) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full min-h-[200px] border-2 border-dashed border-muted-foreground/30 rounded-lg flex items-center justify-center bg-muted/10">
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<Calendar className="h-8 w-8 mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium">타임라인 스케줄러</p>
|
||||||
|
<p className="text-xs mt-1">
|
||||||
|
{config.selectedTable
|
||||||
|
? `테이블: ${config.selectedTable}`
|
||||||
|
: "테이블을 선택하세요"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full flex items-center justify-center bg-muted/10 rounded-lg"
|
||||||
|
style={{ height: config.height || 500 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
<span className="text-sm">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러 상태
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full flex items-center justify-center bg-destructive/10 rounded-lg"
|
||||||
|
style={{ height: config.height || 500 }}
|
||||||
|
>
|
||||||
|
<div className="text-center text-destructive">
|
||||||
|
<p className="text-sm font-medium">오류 발생</p>
|
||||||
|
<p className="text-xs mt-1">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리소스 없음
|
||||||
|
if (resources.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full flex items-center justify-center bg-muted/10 rounded-lg"
|
||||||
|
style={{ height: config.height || 500 }}
|
||||||
|
>
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<Calendar className="h-8 w-8 mx-auto mb-2" />
|
||||||
|
<p className="text-sm font-medium">리소스가 없습니다</p>
|
||||||
|
<p className="text-xs mt-1">리소스 테이블을 설정하세요</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="w-full border rounded-lg overflow-hidden bg-background"
|
||||||
|
style={{
|
||||||
|
height: config.height || 500,
|
||||||
|
maxHeight: config.maxHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 툴바 */}
|
||||||
|
{config.showToolbar !== false && (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
|
||||||
|
{/* 네비게이션 */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{config.showNavigation !== false && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToPrevious}
|
||||||
|
className="h-7 px-2"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToToday}
|
||||||
|
className="h-7 px-2"
|
||||||
|
>
|
||||||
|
오늘
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={goToNext}
|
||||||
|
className="h-7 px-2"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 현재 날짜 범위 표시 */}
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">
|
||||||
|
{viewStartDate.getFullYear()}년 {viewStartDate.getMonth() + 1}월{" "}
|
||||||
|
{viewStartDate.getDate()}일 ~{" "}
|
||||||
|
{viewEndDate.getMonth() + 1}월 {viewEndDate.getDate()}일
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽 컨트롤 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 줌 컨트롤 */}
|
||||||
|
{config.showZoomControls !== false && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleZoomOut}
|
||||||
|
disabled={zoomLevel === "month"}
|
||||||
|
className="h-7 px-2"
|
||||||
|
>
|
||||||
|
<ZoomOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-muted-foreground min-w-[24px] text-center">
|
||||||
|
{zoomLevelOptions.find((o) => o.value === zoomLevel)?.label}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleZoomIn}
|
||||||
|
disabled={zoomLevel === "day"}
|
||||||
|
className="h-7 px-2"
|
||||||
|
>
|
||||||
|
<ZoomIn className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 추가 버튼 */}
|
||||||
|
{config.showAddButton !== false && config.editable && (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddClick}
|
||||||
|
className="h-7"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 타임라인 본문 */}
|
||||||
|
<div
|
||||||
|
className="overflow-auto"
|
||||||
|
style={{
|
||||||
|
height: config.showToolbar !== false
|
||||||
|
? `calc(100% - 48px)`
|
||||||
|
: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="min-w-max">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<TimelineHeader
|
||||||
|
startDate={viewStartDate}
|
||||||
|
endDate={viewEndDate}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
cellWidth={cellWidth}
|
||||||
|
headerHeight={headerHeight}
|
||||||
|
resourceColumnWidth={resourceColumnWidth}
|
||||||
|
showTodayLine={config.showTodayLine}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 리소스 행들 */}
|
||||||
|
<div>
|
||||||
|
{resources.map((resource) => (
|
||||||
|
<ResourceRow
|
||||||
|
key={resource.id}
|
||||||
|
resource={resource}
|
||||||
|
schedules={schedulesByResource.get(resource.id) || []}
|
||||||
|
startDate={viewStartDate}
|
||||||
|
endDate={viewEndDate}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
rowHeight={rowHeight}
|
||||||
|
cellWidth={cellWidth}
|
||||||
|
resourceColumnWidth={resourceColumnWidth}
|
||||||
|
config={config}
|
||||||
|
onScheduleClick={handleScheduleClick}
|
||||||
|
onCellClick={handleCellClick}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onResizeStart={handleResizeStart}
|
||||||
|
onResizeEnd={handleResizeEnd}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,629 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { tableTypeApi } from "@/lib/api/screen";
|
||||||
|
import { TimelineSchedulerConfig } from "./types";
|
||||||
|
import { zoomLevelOptions, statusOptions } from "./config";
|
||||||
|
|
||||||
|
interface TimelineSchedulerConfigPanelProps {
|
||||||
|
config: TimelineSchedulerConfig;
|
||||||
|
onChange: (config: Partial<TimelineSchedulerConfig>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
columnName: string;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelineSchedulerConfigPanel({
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}: TimelineSchedulerConfigPanelProps) {
|
||||||
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
|
const [tableColumns, setTableColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [resourceColumns, setResourceColumns] = useState<ColumnInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [tableSelectOpen, setTableSelectOpen] = useState(false);
|
||||||
|
const [resourceTableSelectOpen, setResourceTableSelectOpen] = useState(false);
|
||||||
|
|
||||||
|
// 테이블 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadTables = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const tableList = await tableTypeApi.getTables();
|
||||||
|
if (Array.isArray(tableList)) {
|
||||||
|
setTables(
|
||||||
|
tableList.map((t: any) => ({
|
||||||
|
tableName: t.table_name || t.tableName,
|
||||||
|
displayName: t.display_name || t.displayName || t.table_name || t.tableName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("테이블 목록 로드 오류:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadTables();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 스케줄 테이블 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadColumns = async () => {
|
||||||
|
if (!config.selectedTable) {
|
||||||
|
setTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const columns = await tableTypeApi.getColumns(config.selectedTable);
|
||||||
|
if (Array.isArray(columns)) {
|
||||||
|
setTableColumns(
|
||||||
|
columns.map((col: any) => ({
|
||||||
|
columnName: col.column_name || col.columnName,
|
||||||
|
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("컬럼 로드 오류:", err);
|
||||||
|
setTableColumns([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadColumns();
|
||||||
|
}, [config.selectedTable]);
|
||||||
|
|
||||||
|
// 리소스 테이블 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadResourceColumns = async () => {
|
||||||
|
if (!config.resourceTable) {
|
||||||
|
setResourceColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const columns = await tableTypeApi.getColumns(config.resourceTable);
|
||||||
|
if (Array.isArray(columns)) {
|
||||||
|
setResourceColumns(
|
||||||
|
columns.map((col: any) => ({
|
||||||
|
columnName: col.column_name || col.columnName,
|
||||||
|
displayName: col.display_name || col.displayName || col.column_name || col.columnName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("리소스 컬럼 로드 오류:", err);
|
||||||
|
setResourceColumns([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadResourceColumns();
|
||||||
|
}, [config.resourceTable]);
|
||||||
|
|
||||||
|
// 설정 업데이트 헬퍼
|
||||||
|
const updateConfig = (updates: Partial<TimelineSchedulerConfig>) => {
|
||||||
|
onChange({ ...config, ...updates });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드 매핑 업데이트
|
||||||
|
const updateFieldMapping = (field: string, value: string) => {
|
||||||
|
updateConfig({
|
||||||
|
fieldMapping: {
|
||||||
|
...config.fieldMapping,
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 리소스 필드 매핑 업데이트
|
||||||
|
const updateResourceFieldMapping = (field: string, value: string) => {
|
||||||
|
updateConfig({
|
||||||
|
resourceFieldMapping: {
|
||||||
|
...config.resourceFieldMapping,
|
||||||
|
id: config.resourceFieldMapping?.id || "id",
|
||||||
|
name: config.resourceFieldMapping?.name || "name",
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<Accordion type="multiple" defaultValue={["table", "mapping", "display"]}>
|
||||||
|
{/* 테이블 설정 */}
|
||||||
|
<AccordionItem value="table">
|
||||||
|
<AccordionTrigger className="text-sm font-medium">
|
||||||
|
테이블 설정
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3 pt-2">
|
||||||
|
{/* 스케줄 테이블 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">스케줄 테이블</Label>
|
||||||
|
<Popover open={tableSelectOpen} onOpenChange={setTableSelectOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={tableSelectOpen}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
로딩 중...
|
||||||
|
</span>
|
||||||
|
) : config.selectedTable ? (
|
||||||
|
tables.find((t) => t.tableName === config.selectedTable)
|
||||||
|
?.displayName || config.selectedTable
|
||||||
|
) : (
|
||||||
|
"테이블 선택..."
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command
|
||||||
|
filter={(value, search) => {
|
||||||
|
const lowerSearch = search.toLowerCase();
|
||||||
|
if (value.toLowerCase().includes(lowerSearch)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs">
|
||||||
|
테이블을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.displayName} ${table.tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
updateConfig({ selectedTable: table.tableName });
|
||||||
|
setTableSelectOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.selectedTable === table.tableName
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{table.displayName}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{table.tableName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리소스 테이블 선택 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">리소스 테이블 (설비/작업자)</Label>
|
||||||
|
<Popover
|
||||||
|
open={resourceTableSelectOpen}
|
||||||
|
onOpenChange={setResourceTableSelectOpen}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={resourceTableSelectOpen}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{config.resourceTable ? (
|
||||||
|
tables.find((t) => t.tableName === config.resourceTable)
|
||||||
|
?.displayName || config.resourceTable
|
||||||
|
) : (
|
||||||
|
"리소스 테이블 선택..."
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command
|
||||||
|
filter={(value, search) => {
|
||||||
|
const lowerSearch = search.toLowerCase();
|
||||||
|
if (value.toLowerCase().includes(lowerSearch)) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs">
|
||||||
|
테이블을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.displayName} ${table.tableName}`}
|
||||||
|
onSelect={() => {
|
||||||
|
updateConfig({ resourceTable: table.tableName });
|
||||||
|
setResourceTableSelectOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.resourceTable === table.tableName
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{table.displayName}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{table.tableName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* 필드 매핑 */}
|
||||||
|
<AccordionItem value="mapping">
|
||||||
|
<AccordionTrigger className="text-sm font-medium">
|
||||||
|
필드 매핑
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3 pt-2">
|
||||||
|
{/* 스케줄 필드 매핑 */}
|
||||||
|
{config.selectedTable && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">스케줄 필드</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{/* ID 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">ID</Label>
|
||||||
|
<Select
|
||||||
|
value={config.fieldMapping?.id || ""}
|
||||||
|
onValueChange={(v) => updateFieldMapping("id", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 리소스 ID 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">리소스 ID</Label>
|
||||||
|
<Select
|
||||||
|
value={config.fieldMapping?.resourceId || ""}
|
||||||
|
onValueChange={(v) => updateFieldMapping("resourceId", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 제목 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">제목</Label>
|
||||||
|
<Select
|
||||||
|
value={config.fieldMapping?.title || ""}
|
||||||
|
onValueChange={(v) => updateFieldMapping("title", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 시작일 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">시작일</Label>
|
||||||
|
<Select
|
||||||
|
value={config.fieldMapping?.startDate || ""}
|
||||||
|
onValueChange={(v) => updateFieldMapping("startDate", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 종료일 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">종료일</Label>
|
||||||
|
<Select
|
||||||
|
value={config.fieldMapping?.endDate || ""}
|
||||||
|
onValueChange={(v) => updateFieldMapping("endDate", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">상태 (선택)</Label>
|
||||||
|
<Select
|
||||||
|
value={config.fieldMapping?.status || "__none__"}
|
||||||
|
onValueChange={(v) => updateFieldMapping("status", v === "__none__" ? "" : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">없음</SelectItem>
|
||||||
|
{tableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 리소스 필드 매핑 */}
|
||||||
|
{config.resourceTable && (
|
||||||
|
<div className="space-y-2 mt-3">
|
||||||
|
<Label className="text-xs font-medium">리소스 필드</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{/* ID 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">ID</Label>
|
||||||
|
<Select
|
||||||
|
value={config.resourceFieldMapping?.id || ""}
|
||||||
|
onValueChange={(v) => updateResourceFieldMapping("id", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{resourceColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이름 필드 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">이름</Label>
|
||||||
|
<Select
|
||||||
|
value={config.resourceFieldMapping?.name || ""}
|
||||||
|
onValueChange={(v) => updateResourceFieldMapping("name", v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{resourceColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
{/* 표시 설정 */}
|
||||||
|
<AccordionItem value="display">
|
||||||
|
<AccordionTrigger className="text-sm font-medium">
|
||||||
|
표시 설정
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="space-y-3 pt-2">
|
||||||
|
{/* 기본 줌 레벨 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">기본 줌 레벨</Label>
|
||||||
|
<Select
|
||||||
|
value={config.defaultZoomLevel || "day"}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateConfig({ defaultZoomLevel: v as any })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{zoomLevelOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 높이 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">높이 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.height || 500}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ height: parseInt(e.target.value) || 500 })
|
||||||
|
}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 행 높이 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">행 높이 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={config.rowHeight || 50}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateConfig({ rowHeight: parseInt(e.target.value) || 50 })
|
||||||
|
}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 토글 스위치들 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">편집 가능</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.editable ?? true}
|
||||||
|
onCheckedChange={(v) => updateConfig({ editable: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">드래그 이동</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.draggable ?? true}
|
||||||
|
onCheckedChange={(v) => updateConfig({ draggable: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">리사이즈</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.resizable ?? true}
|
||||||
|
onCheckedChange={(v) => updateConfig({ resizable: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">오늘 표시선</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.showTodayLine ?? true}
|
||||||
|
onCheckedChange={(v) => updateConfig({ showTodayLine: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">진행률 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.showProgress ?? true}
|
||||||
|
onCheckedChange={(v) => updateConfig({ showProgress: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs">툴바 표시</Label>
|
||||||
|
<Switch
|
||||||
|
checked={config.showToolbar ?? true}
|
||||||
|
onCheckedChange={(v) => updateConfig({ showToolbar: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimelineSchedulerConfigPanel;
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
|
import { V2TimelineSchedulerDefinition } from "./index";
|
||||||
|
import { TimelineSchedulerComponent } from "./TimelineSchedulerComponent";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TimelineScheduler 렌더러
|
||||||
|
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||||
|
*/
|
||||||
|
export class TimelineSchedulerRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = V2TimelineSchedulerDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<TimelineSchedulerComponent
|
||||||
|
{...this.props}
|
||||||
|
config={this.props.component?.componentConfig || {}}
|
||||||
|
isDesignMode={this.props.isDesignMode}
|
||||||
|
formData={this.props.formData}
|
||||||
|
componentId={this.props.component?.id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설정 변경 핸들러
|
||||||
|
protected handleConfigChange = (config: any) => {
|
||||||
|
console.log("📥 TimelineSchedulerRenderer에서 설정 변경 받음:", config);
|
||||||
|
|
||||||
|
// 상위 컴포넌트의 onConfigChange 호출 (화면 설계자에게 알림)
|
||||||
|
if (this.props.onConfigChange) {
|
||||||
|
this.props.onConfigChange(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateComponent({ config });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 값 변경 처리
|
||||||
|
protected handleValueChange = (value: any) => {
|
||||||
|
this.updateComponent({ value });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 등록 실행
|
||||||
|
TimelineSchedulerRenderer.registerSelf();
|
||||||
|
|
||||||
|
// 강제 등록 (디버깅용)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
TimelineSchedulerRenderer.registerSelf();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ TimelineScheduler 강제 등록 실패:", error);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Resource, ScheduleItem, ZoomLevel, TimelineSchedulerConfig } from "../types";
|
||||||
|
import { ScheduleBar } from "./ScheduleBar";
|
||||||
|
|
||||||
|
interface ResourceRowProps {
|
||||||
|
/** 리소스 */
|
||||||
|
resource: Resource;
|
||||||
|
/** 해당 리소스의 스케줄 목록 */
|
||||||
|
schedules: ScheduleItem[];
|
||||||
|
/** 시작 날짜 */
|
||||||
|
startDate: Date;
|
||||||
|
/** 종료 날짜 */
|
||||||
|
endDate: Date;
|
||||||
|
/** 줌 레벨 */
|
||||||
|
zoomLevel: ZoomLevel;
|
||||||
|
/** 행 높이 */
|
||||||
|
rowHeight: number;
|
||||||
|
/** 셀 너비 */
|
||||||
|
cellWidth: number;
|
||||||
|
/** 리소스 컬럼 너비 */
|
||||||
|
resourceColumnWidth: number;
|
||||||
|
/** 설정 */
|
||||||
|
config: TimelineSchedulerConfig;
|
||||||
|
/** 스케줄 클릭 */
|
||||||
|
onScheduleClick?: (schedule: ScheduleItem) => void;
|
||||||
|
/** 빈 셀 클릭 */
|
||||||
|
onCellClick?: (resourceId: string, date: Date) => void;
|
||||||
|
/** 드래그 시작 */
|
||||||
|
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void;
|
||||||
|
/** 드래그 종료 */
|
||||||
|
onDragEnd?: () => void;
|
||||||
|
/** 리사이즈 시작 */
|
||||||
|
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void;
|
||||||
|
/** 리사이즈 종료 */
|
||||||
|
onResizeEnd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 차이 계산 (일수)
|
||||||
|
*/
|
||||||
|
const getDaysDiff = (start: Date, end: Date): number => {
|
||||||
|
const startTime = new Date(start).setHours(0, 0, 0, 0);
|
||||||
|
const endTime = new Date(end).setHours(0, 0, 0, 0);
|
||||||
|
return Math.round((endTime - startTime) / (1000 * 60 * 60 * 24));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 범위 내의 셀 개수 계산
|
||||||
|
*/
|
||||||
|
const getCellCount = (startDate: Date, endDate: Date): number => {
|
||||||
|
return getDaysDiff(startDate, endDate) + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResourceRow({
|
||||||
|
resource,
|
||||||
|
schedules,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
zoomLevel,
|
||||||
|
rowHeight,
|
||||||
|
cellWidth,
|
||||||
|
resourceColumnWidth,
|
||||||
|
config,
|
||||||
|
onScheduleClick,
|
||||||
|
onCellClick,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onResizeStart,
|
||||||
|
onResizeEnd,
|
||||||
|
}: ResourceRowProps) {
|
||||||
|
// 총 셀 개수
|
||||||
|
const totalCells = useMemo(() => getCellCount(startDate, endDate), [startDate, endDate]);
|
||||||
|
|
||||||
|
// 총 그리드 너비
|
||||||
|
const gridWidth = totalCells * cellWidth;
|
||||||
|
|
||||||
|
// 오늘 날짜
|
||||||
|
const today = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 스케줄 바 위치 계산
|
||||||
|
const schedulePositions = useMemo(() => {
|
||||||
|
return schedules.map((schedule) => {
|
||||||
|
const scheduleStart = new Date(schedule.startDate);
|
||||||
|
const scheduleEnd = new Date(schedule.endDate);
|
||||||
|
scheduleStart.setHours(0, 0, 0, 0);
|
||||||
|
scheduleEnd.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// 시작 위치 계산
|
||||||
|
const startOffset = getDaysDiff(startDate, scheduleStart);
|
||||||
|
const left = Math.max(0, startOffset * cellWidth);
|
||||||
|
|
||||||
|
// 너비 계산
|
||||||
|
const durationDays = getDaysDiff(scheduleStart, scheduleEnd) + 1;
|
||||||
|
const visibleStartOffset = Math.max(0, startOffset);
|
||||||
|
const visibleEndOffset = Math.min(
|
||||||
|
totalCells,
|
||||||
|
startOffset + durationDays
|
||||||
|
);
|
||||||
|
const width = Math.max(cellWidth, (visibleEndOffset - visibleStartOffset) * cellWidth);
|
||||||
|
|
||||||
|
return {
|
||||||
|
schedule,
|
||||||
|
position: {
|
||||||
|
left: resourceColumnWidth + left,
|
||||||
|
top: 0,
|
||||||
|
width,
|
||||||
|
height: rowHeight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [schedules, startDate, cellWidth, resourceColumnWidth, rowHeight, totalCells]);
|
||||||
|
|
||||||
|
// 그리드 셀 클릭 핸들러
|
||||||
|
const handleGridClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!onCellClick) return;
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const cellIndex = Math.floor(x / cellWidth);
|
||||||
|
|
||||||
|
const clickedDate = new Date(startDate);
|
||||||
|
clickedDate.setDate(clickedDate.getDate() + cellIndex);
|
||||||
|
|
||||||
|
onCellClick(resource.id, clickedDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex border-b hover:bg-muted/20"
|
||||||
|
style={{ height: rowHeight }}
|
||||||
|
>
|
||||||
|
{/* 리소스 컬럼 */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 border-r bg-muted/30 flex items-center px-3 sticky left-0 z-10"
|
||||||
|
style={{ width: resourceColumnWidth }}
|
||||||
|
>
|
||||||
|
<div className="truncate">
|
||||||
|
<div className="font-medium text-sm truncate">{resource.name}</div>
|
||||||
|
{resource.group && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{resource.group}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타임라인 그리드 */}
|
||||||
|
<div
|
||||||
|
className="relative flex-1"
|
||||||
|
style={{ width: gridWidth }}
|
||||||
|
onClick={handleGridClick}
|
||||||
|
>
|
||||||
|
{/* 배경 그리드 */}
|
||||||
|
<div className="absolute inset-0 flex">
|
||||||
|
{Array.from({ length: totalCells }).map((_, idx) => {
|
||||||
|
const cellDate = new Date(startDate);
|
||||||
|
cellDate.setDate(cellDate.getDate() + idx);
|
||||||
|
const isWeekend = cellDate.getDay() === 0 || cellDate.getDay() === 6;
|
||||||
|
const isToday = cellDate.getTime() === today.getTime();
|
||||||
|
const isMonthStart = cellDate.getDate() === 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"border-r h-full",
|
||||||
|
isWeekend && "bg-muted/20",
|
||||||
|
isToday && "bg-primary/5",
|
||||||
|
isMonthStart && "border-l-2 border-l-primary/20"
|
||||||
|
)}
|
||||||
|
style={{ width: cellWidth }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 스케줄 바들 */}
|
||||||
|
{schedulePositions.map(({ schedule, position }) => (
|
||||||
|
<ScheduleBar
|
||||||
|
key={schedule.id}
|
||||||
|
schedule={schedule}
|
||||||
|
position={{
|
||||||
|
...position,
|
||||||
|
left: position.left - resourceColumnWidth, // 상대 위치
|
||||||
|
}}
|
||||||
|
config={config}
|
||||||
|
draggable={config.draggable}
|
||||||
|
resizable={config.resizable}
|
||||||
|
onClick={() => onScheduleClick?.(schedule)}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onResizeStart={onResizeStart}
|
||||||
|
onResizeEnd={onResizeEnd}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useRef } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ScheduleItem, ScheduleBarPosition, TimelineSchedulerConfig } from "../types";
|
||||||
|
import { statusOptions } from "../config";
|
||||||
|
|
||||||
|
interface ScheduleBarProps {
|
||||||
|
/** 스케줄 항목 */
|
||||||
|
schedule: ScheduleItem;
|
||||||
|
/** 위치 정보 */
|
||||||
|
position: ScheduleBarPosition;
|
||||||
|
/** 설정 */
|
||||||
|
config: TimelineSchedulerConfig;
|
||||||
|
/** 드래그 가능 여부 */
|
||||||
|
draggable?: boolean;
|
||||||
|
/** 리사이즈 가능 여부 */
|
||||||
|
resizable?: boolean;
|
||||||
|
/** 클릭 이벤트 */
|
||||||
|
onClick?: (schedule: ScheduleItem) => void;
|
||||||
|
/** 드래그 시작 */
|
||||||
|
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void;
|
||||||
|
/** 드래그 중 */
|
||||||
|
onDrag?: (deltaX: number, deltaY: number) => void;
|
||||||
|
/** 드래그 종료 */
|
||||||
|
onDragEnd?: () => void;
|
||||||
|
/** 리사이즈 시작 */
|
||||||
|
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void;
|
||||||
|
/** 리사이즈 중 */
|
||||||
|
onResize?: (deltaX: number, direction: "start" | "end") => void;
|
||||||
|
/** 리사이즈 종료 */
|
||||||
|
onResizeEnd?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScheduleBar({
|
||||||
|
schedule,
|
||||||
|
position,
|
||||||
|
config,
|
||||||
|
draggable = true,
|
||||||
|
resizable = true,
|
||||||
|
onClick,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
onResizeStart,
|
||||||
|
onResizeEnd,
|
||||||
|
}: ScheduleBarProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const barRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 상태에 따른 색상
|
||||||
|
const statusColor = schedule.color ||
|
||||||
|
config.statusColors?.[schedule.status] ||
|
||||||
|
statusOptions.find((s) => s.value === schedule.status)?.color ||
|
||||||
|
"#3b82f6";
|
||||||
|
|
||||||
|
// 진행률 바 너비
|
||||||
|
const progressWidth = config.showProgress && schedule.progress !== undefined
|
||||||
|
? `${schedule.progress}%`
|
||||||
|
: "0%";
|
||||||
|
|
||||||
|
// 드래그 시작 핸들러
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!draggable || isResizing) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
onDragStart?.(schedule, e);
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
// 드래그 중 로직은 부모에서 처리
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
onDragEnd?.();
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
},
|
||||||
|
[draggable, isResizing, schedule, onDragStart, onDragEnd]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 리사이즈 시작 핸들러
|
||||||
|
const handleResizeStart = useCallback(
|
||||||
|
(direction: "start" | "end", e: React.MouseEvent) => {
|
||||||
|
if (!resizable) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsResizing(true);
|
||||||
|
onResizeStart?.(schedule, direction, e);
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
// 리사이즈 중 로직은 부모에서 처리
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsResizing(false);
|
||||||
|
onResizeEnd?.();
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
},
|
||||||
|
[resizable, schedule, onResizeStart, onResizeEnd]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 클릭 핸들러
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (isDragging || isResizing) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.(schedule);
|
||||||
|
},
|
||||||
|
[isDragging, isResizing, onClick, schedule]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={barRef}
|
||||||
|
className={cn(
|
||||||
|
"absolute rounded-md shadow-sm cursor-pointer transition-shadow",
|
||||||
|
"hover:shadow-md hover:z-10",
|
||||||
|
isDragging && "opacity-70 shadow-lg z-20",
|
||||||
|
isResizing && "z-20",
|
||||||
|
draggable && "cursor-grab",
|
||||||
|
isDragging && "cursor-grabbing"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: position.left,
|
||||||
|
top: position.top + 4,
|
||||||
|
width: position.width,
|
||||||
|
height: position.height - 8,
|
||||||
|
backgroundColor: statusColor,
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
>
|
||||||
|
{/* 진행률 바 */}
|
||||||
|
{config.showProgress && schedule.progress !== undefined && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 rounded-l-md opacity-30 bg-white"
|
||||||
|
style={{ width: progressWidth }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
<div className="relative z-10 px-2 py-1 text-xs text-white truncate font-medium">
|
||||||
|
{schedule.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 진행률 텍스트 */}
|
||||||
|
{config.showProgress && schedule.progress !== undefined && (
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-white/80 font-medium">
|
||||||
|
{schedule.progress}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 리사이즈 핸들 - 왼쪽 */}
|
||||||
|
{resizable && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-white/20 rounded-l-md"
|
||||||
|
onMouseDown={(e) => handleResizeStart("start", e)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 리사이즈 핸들 - 오른쪽 */}
|
||||||
|
{resizable && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-2 cursor-ew-resize hover:bg-white/20 rounded-r-md"
|
||||||
|
onMouseDown={(e) => handleResizeStart("end", e)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { DateCell, ZoomLevel } from "../types";
|
||||||
|
import { dayLabels, monthLabels } from "../config";
|
||||||
|
|
||||||
|
interface TimelineHeaderProps {
|
||||||
|
/** 시작 날짜 */
|
||||||
|
startDate: Date;
|
||||||
|
/** 종료 날짜 */
|
||||||
|
endDate: Date;
|
||||||
|
/** 줌 레벨 */
|
||||||
|
zoomLevel: ZoomLevel;
|
||||||
|
/** 셀 너비 */
|
||||||
|
cellWidth: number;
|
||||||
|
/** 헤더 높이 */
|
||||||
|
headerHeight: number;
|
||||||
|
/** 리소스 컬럼 너비 */
|
||||||
|
resourceColumnWidth: number;
|
||||||
|
/** 오늘 표시선 */
|
||||||
|
showTodayLine?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 범위 내의 모든 날짜 셀 생성
|
||||||
|
*/
|
||||||
|
const generateDateCells = (
|
||||||
|
startDate: Date,
|
||||||
|
endDate: Date,
|
||||||
|
zoomLevel: ZoomLevel
|
||||||
|
): DateCell[] => {
|
||||||
|
const cells: DateCell[] = [];
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const current = new Date(startDate);
|
||||||
|
current.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
while (current <= endDate) {
|
||||||
|
const date = new Date(current);
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
const isToday = date.getTime() === today.getTime();
|
||||||
|
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||||
|
const isMonthStart = date.getDate() === 1;
|
||||||
|
|
||||||
|
let label = "";
|
||||||
|
if (zoomLevel === "day") {
|
||||||
|
label = `${date.getDate()}(${dayLabels[dayOfWeek]})`;
|
||||||
|
} else if (zoomLevel === "week") {
|
||||||
|
// 주간: 월요일 기준 주 시작
|
||||||
|
if (dayOfWeek === 1 || cells.length === 0) {
|
||||||
|
label = `${date.getMonth() + 1}/${date.getDate()}`;
|
||||||
|
}
|
||||||
|
} else if (zoomLevel === "month") {
|
||||||
|
// 월간: 월 시작일만 표시
|
||||||
|
if (isMonthStart || cells.length === 0) {
|
||||||
|
label = monthLabels[date.getMonth()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cells.push({
|
||||||
|
date,
|
||||||
|
label,
|
||||||
|
isToday,
|
||||||
|
isWeekend,
|
||||||
|
isMonthStart,
|
||||||
|
});
|
||||||
|
|
||||||
|
current.setDate(current.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월 헤더 그룹 생성 (상단 행)
|
||||||
|
*/
|
||||||
|
const generateMonthGroups = (
|
||||||
|
cells: DateCell[]
|
||||||
|
): { month: string; year: number; count: number }[] => {
|
||||||
|
const groups: { month: string; year: number; count: number }[] = [];
|
||||||
|
|
||||||
|
cells.forEach((cell) => {
|
||||||
|
const month = monthLabels[cell.date.getMonth()];
|
||||||
|
const year = cell.date.getFullYear();
|
||||||
|
|
||||||
|
if (
|
||||||
|
groups.length === 0 ||
|
||||||
|
groups[groups.length - 1].month !== month ||
|
||||||
|
groups[groups.length - 1].year !== year
|
||||||
|
) {
|
||||||
|
groups.push({ month, year, count: 1 });
|
||||||
|
} else {
|
||||||
|
groups[groups.length - 1].count++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TimelineHeader({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
zoomLevel,
|
||||||
|
cellWidth,
|
||||||
|
headerHeight,
|
||||||
|
resourceColumnWidth,
|
||||||
|
showTodayLine = true,
|
||||||
|
}: TimelineHeaderProps) {
|
||||||
|
// 날짜 셀 생성
|
||||||
|
const dateCells = useMemo(
|
||||||
|
() => generateDateCells(startDate, endDate, zoomLevel),
|
||||||
|
[startDate, endDate, zoomLevel]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 월 그룹 생성
|
||||||
|
const monthGroups = useMemo(() => generateMonthGroups(dateCells), [dateCells]);
|
||||||
|
|
||||||
|
// 오늘 위치 계산
|
||||||
|
const todayPosition = useMemo(() => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const todayIndex = dateCells.findIndex(
|
||||||
|
(cell) => cell.date.getTime() === today.getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (todayIndex === -1) return null;
|
||||||
|
|
||||||
|
return resourceColumnWidth + todayIndex * cellWidth + cellWidth / 2;
|
||||||
|
}, [dateCells, cellWidth, resourceColumnWidth]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="sticky top-0 z-20 border-b bg-background"
|
||||||
|
style={{ height: headerHeight }}
|
||||||
|
>
|
||||||
|
{/* 상단 행: 월/년도 */}
|
||||||
|
<div className="flex" style={{ height: headerHeight / 2 }}>
|
||||||
|
{/* 리소스 컬럼 헤더 */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 border-r bg-muted/50 flex items-center justify-center font-medium text-sm"
|
||||||
|
style={{ width: resourceColumnWidth }}
|
||||||
|
>
|
||||||
|
리소스
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 월 그룹 */}
|
||||||
|
{monthGroups.map((group, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${group.year}-${group.month}-${idx}`}
|
||||||
|
className="border-r flex items-center justify-center text-xs font-medium text-muted-foreground"
|
||||||
|
style={{ width: group.count * cellWidth }}
|
||||||
|
>
|
||||||
|
{group.year}년 {group.month}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 행: 일자 */}
|
||||||
|
<div className="flex" style={{ height: headerHeight / 2 }}>
|
||||||
|
{/* 리소스 컬럼 (빈칸) */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 border-r bg-muted/50"
|
||||||
|
style={{ width: resourceColumnWidth }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 날짜 셀 */}
|
||||||
|
{dateCells.map((cell, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"border-r flex items-center justify-center text-xs",
|
||||||
|
cell.isToday && "bg-primary/10 font-bold text-primary",
|
||||||
|
cell.isWeekend && !cell.isToday && "bg-muted/30 text-muted-foreground",
|
||||||
|
cell.isMonthStart && "border-l-2 border-l-primary/30"
|
||||||
|
)}
|
||||||
|
style={{ width: cellWidth }}
|
||||||
|
>
|
||||||
|
{cell.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오늘 표시선 */}
|
||||||
|
{showTodayLine && todayPosition !== null && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-0.5 bg-primary z-30 pointer-events-none"
|
||||||
|
style={{ left: todayPosition }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { TimelineHeader } from "./TimelineHeader";
|
||||||
|
export { ScheduleBar } from "./ScheduleBar";
|
||||||
|
export { ResourceRow } from "./ResourceRow";
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TimelineSchedulerConfig, ZoomLevel } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 타임라인 스케줄러 설정
|
||||||
|
*/
|
||||||
|
export const defaultTimelineSchedulerConfig: Partial<TimelineSchedulerConfig> = {
|
||||||
|
defaultZoomLevel: "day",
|
||||||
|
editable: true,
|
||||||
|
draggable: true,
|
||||||
|
resizable: true,
|
||||||
|
rowHeight: 50,
|
||||||
|
headerHeight: 60,
|
||||||
|
resourceColumnWidth: 150,
|
||||||
|
cellWidth: {
|
||||||
|
day: 60,
|
||||||
|
week: 120,
|
||||||
|
month: 40,
|
||||||
|
},
|
||||||
|
showConflicts: true,
|
||||||
|
showProgress: true,
|
||||||
|
showTodayLine: true,
|
||||||
|
showToolbar: true,
|
||||||
|
showZoomControls: true,
|
||||||
|
showNavigation: true,
|
||||||
|
showAddButton: true,
|
||||||
|
height: 500,
|
||||||
|
statusColors: {
|
||||||
|
planned: "#3b82f6", // blue-500
|
||||||
|
in_progress: "#f59e0b", // amber-500
|
||||||
|
completed: "#10b981", // emerald-500
|
||||||
|
delayed: "#ef4444", // red-500
|
||||||
|
cancelled: "#6b7280", // gray-500
|
||||||
|
},
|
||||||
|
fieldMapping: {
|
||||||
|
id: "id",
|
||||||
|
resourceId: "resource_id",
|
||||||
|
title: "title",
|
||||||
|
startDate: "start_date",
|
||||||
|
endDate: "end_date",
|
||||||
|
status: "status",
|
||||||
|
progress: "progress",
|
||||||
|
},
|
||||||
|
resourceFieldMapping: {
|
||||||
|
id: "id",
|
||||||
|
name: "name",
|
||||||
|
group: "group",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 줌 레벨 옵션
|
||||||
|
*/
|
||||||
|
export const zoomLevelOptions: { value: ZoomLevel; label: string }[] = [
|
||||||
|
{ value: "day", label: "일" },
|
||||||
|
{ value: "week", label: "주" },
|
||||||
|
{ value: "month", label: "월" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 옵션
|
||||||
|
*/
|
||||||
|
export const statusOptions = [
|
||||||
|
{ value: "planned", label: "계획됨", color: "#3b82f6" },
|
||||||
|
{ value: "in_progress", label: "진행중", color: "#f59e0b" },
|
||||||
|
{ value: "completed", label: "완료", color: "#10b981" },
|
||||||
|
{ value: "delayed", label: "지연", color: "#ef4444" },
|
||||||
|
{ value: "cancelled", label: "취소", color: "#6b7280" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 줌 레벨별 표시 일수
|
||||||
|
*/
|
||||||
|
export const zoomLevelDays: Record<ZoomLevel, number> = {
|
||||||
|
day: 14, // 2주
|
||||||
|
week: 56, // 8주
|
||||||
|
month: 90, // 3개월
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요일 라벨 (한글)
|
||||||
|
*/
|
||||||
|
export const dayLabels = ["일", "월", "화", "수", "목", "금", "토"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월 라벨 (한글)
|
||||||
|
*/
|
||||||
|
export const monthLabels = [
|
||||||
|
"1월",
|
||||||
|
"2월",
|
||||||
|
"3월",
|
||||||
|
"4월",
|
||||||
|
"5월",
|
||||||
|
"6월",
|
||||||
|
"7월",
|
||||||
|
"8월",
|
||||||
|
"9월",
|
||||||
|
"10월",
|
||||||
|
"11월",
|
||||||
|
"12월",
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
TimelineSchedulerConfig,
|
||||||
|
ScheduleItem,
|
||||||
|
Resource,
|
||||||
|
ZoomLevel,
|
||||||
|
UseTimelineDataResult,
|
||||||
|
} from "../types";
|
||||||
|
import { zoomLevelDays, defaultTimelineSchedulerConfig } from "../config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜를 ISO 문자열로 변환 (시간 제외)
|
||||||
|
*/
|
||||||
|
const toDateString = (date: Date): string => {
|
||||||
|
return date.toISOString().split("T")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 더하기
|
||||||
|
*/
|
||||||
|
const addDays = (date: Date, days: number): Date => {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setDate(result.getDate() + days);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임라인 데이터를 관리하는 훅
|
||||||
|
*/
|
||||||
|
export function useTimelineData(
|
||||||
|
config: TimelineSchedulerConfig,
|
||||||
|
externalSchedules?: ScheduleItem[],
|
||||||
|
externalResources?: Resource[]
|
||||||
|
): UseTimelineDataResult {
|
||||||
|
// 상태
|
||||||
|
const [schedules, setSchedules] = useState<ScheduleItem[]>([]);
|
||||||
|
const [resources, setResources] = useState<Resource[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState<ZoomLevel>(
|
||||||
|
config.defaultZoomLevel || "day"
|
||||||
|
);
|
||||||
|
const [viewStartDate, setViewStartDate] = useState<Date>(() => {
|
||||||
|
if (config.initialDate) {
|
||||||
|
return new Date(config.initialDate);
|
||||||
|
}
|
||||||
|
// 오늘 기준 1주일 전부터 시작
|
||||||
|
const today = new Date();
|
||||||
|
today.setDate(today.getDate() - 7);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return today;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 표시 종료일 계산
|
||||||
|
const viewEndDate = useMemo(() => {
|
||||||
|
const days = zoomLevelDays[zoomLevel];
|
||||||
|
return addDays(viewStartDate, days);
|
||||||
|
}, [viewStartDate, zoomLevel]);
|
||||||
|
|
||||||
|
// 테이블명
|
||||||
|
const tableName = config.useCustomTable
|
||||||
|
? config.customTableName
|
||||||
|
: config.selectedTable;
|
||||||
|
|
||||||
|
const resourceTableName = config.resourceTable;
|
||||||
|
|
||||||
|
// 필드 매핑
|
||||||
|
const fieldMapping = config.fieldMapping || defaultTimelineSchedulerConfig.fieldMapping!;
|
||||||
|
const resourceFieldMapping =
|
||||||
|
config.resourceFieldMapping || defaultTimelineSchedulerConfig.resourceFieldMapping!;
|
||||||
|
|
||||||
|
// 스케줄 데이터 로드
|
||||||
|
const fetchSchedules = useCallback(async () => {
|
||||||
|
if (externalSchedules) {
|
||||||
|
setSchedules(externalSchedules);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
setSchedules([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${tableName}/data`,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
size: 10000,
|
||||||
|
autoFilter: true,
|
||||||
|
search: {
|
||||||
|
// 표시 범위 내의 스케줄만 조회
|
||||||
|
[fieldMapping.startDate]: {
|
||||||
|
value: toDateString(viewEndDate),
|
||||||
|
operator: "lte",
|
||||||
|
},
|
||||||
|
[fieldMapping.endDate]: {
|
||||||
|
value: toDateString(viewStartDate),
|
||||||
|
operator: "gte",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData =
|
||||||
|
response.data?.data?.data || response.data?.data || [];
|
||||||
|
const rawData = Array.isArray(responseData) ? responseData : [];
|
||||||
|
|
||||||
|
// 데이터를 ScheduleItem 형태로 변환
|
||||||
|
const mappedSchedules: ScheduleItem[] = rawData.map((row: any) => ({
|
||||||
|
id: String(row[fieldMapping.id] || ""),
|
||||||
|
resourceId: String(row[fieldMapping.resourceId] || ""),
|
||||||
|
title: String(row[fieldMapping.title] || ""),
|
||||||
|
startDate: row[fieldMapping.startDate] || "",
|
||||||
|
endDate: row[fieldMapping.endDate] || "",
|
||||||
|
status: fieldMapping.status
|
||||||
|
? row[fieldMapping.status] || "planned"
|
||||||
|
: "planned",
|
||||||
|
progress: fieldMapping.progress
|
||||||
|
? Number(row[fieldMapping.progress]) || 0
|
||||||
|
: undefined,
|
||||||
|
color: fieldMapping.color ? row[fieldMapping.color] : undefined,
|
||||||
|
data: row,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSchedules(mappedSchedules);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "스케줄 데이터 로드 중 오류 발생");
|
||||||
|
setSchedules([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
tableName,
|
||||||
|
externalSchedules,
|
||||||
|
fieldMapping,
|
||||||
|
viewStartDate,
|
||||||
|
viewEndDate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 리소스 데이터 로드
|
||||||
|
const fetchResources = useCallback(async () => {
|
||||||
|
if (externalResources) {
|
||||||
|
setResources(externalResources);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resourceTableName) {
|
||||||
|
setResources([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${resourceTableName}/data`,
|
||||||
|
{
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
autoFilter: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData =
|
||||||
|
response.data?.data?.data || response.data?.data || [];
|
||||||
|
const rawData = Array.isArray(responseData) ? responseData : [];
|
||||||
|
|
||||||
|
// 데이터를 Resource 형태로 변환
|
||||||
|
const mappedResources: Resource[] = rawData.map((row: any) => ({
|
||||||
|
id: String(row[resourceFieldMapping.id] || ""),
|
||||||
|
name: String(row[resourceFieldMapping.name] || ""),
|
||||||
|
group: resourceFieldMapping.group
|
||||||
|
? row[resourceFieldMapping.group]
|
||||||
|
: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setResources(mappedResources);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("리소스 로드 오류:", err);
|
||||||
|
setResources([]);
|
||||||
|
}
|
||||||
|
}, [resourceTableName, externalResources, resourceFieldMapping]);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSchedules();
|
||||||
|
}, [fetchSchedules]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchResources();
|
||||||
|
}, [fetchResources]);
|
||||||
|
|
||||||
|
// 네비게이션 함수들
|
||||||
|
const goToPrevious = useCallback(() => {
|
||||||
|
const days = zoomLevelDays[zoomLevel];
|
||||||
|
setViewStartDate((prev) => addDays(prev, -days));
|
||||||
|
}, [zoomLevel]);
|
||||||
|
|
||||||
|
const goToNext = useCallback(() => {
|
||||||
|
const days = zoomLevelDays[zoomLevel];
|
||||||
|
setViewStartDate((prev) => addDays(prev, days));
|
||||||
|
}, [zoomLevel]);
|
||||||
|
|
||||||
|
const goToToday = useCallback(() => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setDate(today.getDate() - 7);
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
setViewStartDate(today);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goToDate = useCallback((date: Date) => {
|
||||||
|
const newDate = new Date(date);
|
||||||
|
newDate.setDate(newDate.getDate() - 7);
|
||||||
|
newDate.setHours(0, 0, 0, 0);
|
||||||
|
setViewStartDate(newDate);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 스케줄 업데이트
|
||||||
|
const updateSchedule = useCallback(
|
||||||
|
async (id: string, updates: Partial<ScheduleItem>) => {
|
||||||
|
if (!tableName || !config.editable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 필드 매핑 역변환
|
||||||
|
const updateData: Record<string, any> = {};
|
||||||
|
if (updates.startDate) updateData[fieldMapping.startDate] = updates.startDate;
|
||||||
|
if (updates.endDate) updateData[fieldMapping.endDate] = updates.endDate;
|
||||||
|
if (updates.resourceId) updateData[fieldMapping.resourceId] = updates.resourceId;
|
||||||
|
if (updates.title) updateData[fieldMapping.title] = updates.title;
|
||||||
|
if (updates.status && fieldMapping.status)
|
||||||
|
updateData[fieldMapping.status] = updates.status;
|
||||||
|
if (updates.progress !== undefined && fieldMapping.progress)
|
||||||
|
updateData[fieldMapping.progress] = updates.progress;
|
||||||
|
|
||||||
|
await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, updateData);
|
||||||
|
|
||||||
|
// 로컬 상태 업데이트
|
||||||
|
setSchedules((prev) =>
|
||||||
|
prev.map((s) => (s.id === id ? { ...s, ...updates } : s))
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("스케줄 업데이트 오류:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tableName, fieldMapping, config.editable]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 스케줄 추가
|
||||||
|
const addSchedule = useCallback(
|
||||||
|
async (schedule: Omit<ScheduleItem, "id">) => {
|
||||||
|
if (!tableName || !config.editable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 필드 매핑 역변환
|
||||||
|
const insertData: Record<string, any> = {
|
||||||
|
[fieldMapping.resourceId]: schedule.resourceId,
|
||||||
|
[fieldMapping.title]: schedule.title,
|
||||||
|
[fieldMapping.startDate]: schedule.startDate,
|
||||||
|
[fieldMapping.endDate]: schedule.endDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fieldMapping.status) insertData[fieldMapping.status] = schedule.status;
|
||||||
|
if (fieldMapping.progress && schedule.progress !== undefined)
|
||||||
|
insertData[fieldMapping.progress] = schedule.progress;
|
||||||
|
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${tableName}/data`,
|
||||||
|
insertData
|
||||||
|
);
|
||||||
|
|
||||||
|
const newId = response.data?.data?.id || Date.now().toString();
|
||||||
|
|
||||||
|
// 로컬 상태 업데이트
|
||||||
|
setSchedules((prev) => [...prev, { ...schedule, id: newId }]);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("스케줄 추가 오류:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tableName, fieldMapping, config.editable]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 스케줄 삭제
|
||||||
|
const deleteSchedule = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
if (!tableName || !config.editable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/table-management/tables/${tableName}/data/${id}`);
|
||||||
|
|
||||||
|
// 로컬 상태 업데이트
|
||||||
|
setSchedules((prev) => prev.filter((s) => s.id !== id));
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("스케줄 삭제 오류:", err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[tableName, config.editable]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 새로고침
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
fetchSchedules();
|
||||||
|
fetchResources();
|
||||||
|
}, [fetchSchedules, fetchResources]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
schedules,
|
||||||
|
resources,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
zoomLevel,
|
||||||
|
setZoomLevel,
|
||||||
|
viewStartDate,
|
||||||
|
viewEndDate,
|
||||||
|
goToPrevious,
|
||||||
|
goToNext,
|
||||||
|
goToToday,
|
||||||
|
goToDate,
|
||||||
|
updateSchedule,
|
||||||
|
addSchedule,
|
||||||
|
deleteSchedule,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { TimelineSchedulerComponent } from "./TimelineSchedulerComponent";
|
||||||
|
import { TimelineSchedulerConfigPanel } from "./TimelineSchedulerConfigPanel";
|
||||||
|
import { defaultTimelineSchedulerConfig } from "./config";
|
||||||
|
import { TimelineSchedulerConfig } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2-timeline-scheduler 컴포넌트 정의
|
||||||
|
* 간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트
|
||||||
|
*/
|
||||||
|
export const V2TimelineSchedulerDefinition = createComponentDefinition({
|
||||||
|
id: "v2-timeline-scheduler",
|
||||||
|
name: "타임라인 스케줄러",
|
||||||
|
nameEng: "Timeline Scheduler Component",
|
||||||
|
description: "간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트",
|
||||||
|
category: ComponentCategory.DISPLAY,
|
||||||
|
webType: "text",
|
||||||
|
component: TimelineSchedulerComponent,
|
||||||
|
configPanel: TimelineSchedulerConfigPanel,
|
||||||
|
defaultConfig: defaultTimelineSchedulerConfig as TimelineSchedulerConfig,
|
||||||
|
defaultSize: {
|
||||||
|
width: 1000,
|
||||||
|
height: 500,
|
||||||
|
},
|
||||||
|
icon: "Calendar",
|
||||||
|
tags: ["타임라인", "스케줄", "간트차트", "일정", "계획"],
|
||||||
|
version: "2.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
documentation: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
export { TimelineSchedulerComponent } from "./TimelineSchedulerComponent";
|
||||||
|
export { TimelineSchedulerConfigPanel } from "./TimelineSchedulerConfigPanel";
|
||||||
|
export * from "./types";
|
||||||
|
export * from "./config";
|
||||||
|
|
@ -0,0 +1,363 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ComponentConfig } from "@/types/component";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 줌 레벨 (시간 단위)
|
||||||
|
*/
|
||||||
|
export type ZoomLevel = "day" | "week" | "month";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 상태
|
||||||
|
*/
|
||||||
|
export type ScheduleStatus =
|
||||||
|
| "planned"
|
||||||
|
| "in_progress"
|
||||||
|
| "completed"
|
||||||
|
| "delayed"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 항목 (간트 바)
|
||||||
|
*/
|
||||||
|
export interface ScheduleItem {
|
||||||
|
/** 고유 ID */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** 리소스 ID (설비/작업자) */
|
||||||
|
resourceId: string;
|
||||||
|
|
||||||
|
/** 표시 제목 */
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/** 시작 일시 (ISO 8601) */
|
||||||
|
startDate: string;
|
||||||
|
|
||||||
|
/** 종료 일시 (ISO 8601) */
|
||||||
|
endDate: string;
|
||||||
|
|
||||||
|
/** 상태 */
|
||||||
|
status: ScheduleStatus;
|
||||||
|
|
||||||
|
/** 진행률 (0-100) */
|
||||||
|
progress?: number;
|
||||||
|
|
||||||
|
/** 색상 (CSS color) */
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
/** 추가 데이터 */
|
||||||
|
data?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리소스 (행 - 설비/작업자)
|
||||||
|
*/
|
||||||
|
export interface Resource {
|
||||||
|
/** 리소스 ID */
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
/** 표시명 */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** 그룹 (선택) */
|
||||||
|
group?: string;
|
||||||
|
|
||||||
|
/** 아이콘 (선택) */
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
/** 용량 (선택, 충돌 계산용) */
|
||||||
|
capacity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 매핑 설정
|
||||||
|
*/
|
||||||
|
export interface FieldMapping {
|
||||||
|
/** ID 필드 */
|
||||||
|
id: string;
|
||||||
|
/** 리소스 ID 필드 */
|
||||||
|
resourceId: string;
|
||||||
|
/** 제목 필드 */
|
||||||
|
title: string;
|
||||||
|
/** 시작일 필드 */
|
||||||
|
startDate: string;
|
||||||
|
/** 종료일 필드 */
|
||||||
|
endDate: string;
|
||||||
|
/** 상태 필드 (선택) */
|
||||||
|
status?: string;
|
||||||
|
/** 진행률 필드 (선택) */
|
||||||
|
progress?: string;
|
||||||
|
/** 색상 필드 (선택) */
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리소스 필드 매핑 설정
|
||||||
|
*/
|
||||||
|
export interface ResourceFieldMapping {
|
||||||
|
/** ID 필드 */
|
||||||
|
id: string;
|
||||||
|
/** 이름 필드 */
|
||||||
|
name: string;
|
||||||
|
/** 그룹 필드 (선택) */
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타임라인 스케줄러 설정
|
||||||
|
*/
|
||||||
|
export interface TimelineSchedulerConfig extends ComponentConfig {
|
||||||
|
/** 스케줄 데이터 테이블명 */
|
||||||
|
selectedTable?: string;
|
||||||
|
|
||||||
|
/** 리소스 테이블명 */
|
||||||
|
resourceTable?: string;
|
||||||
|
|
||||||
|
/** 스케줄 필드 매핑 */
|
||||||
|
fieldMapping: FieldMapping;
|
||||||
|
|
||||||
|
/** 리소스 필드 매핑 */
|
||||||
|
resourceFieldMapping?: ResourceFieldMapping;
|
||||||
|
|
||||||
|
/** 초기 줌 레벨 */
|
||||||
|
defaultZoomLevel?: ZoomLevel;
|
||||||
|
|
||||||
|
/** 초기 표시 날짜 (ISO 8601) */
|
||||||
|
initialDate?: string;
|
||||||
|
|
||||||
|
/** 편집 가능 여부 */
|
||||||
|
editable?: boolean;
|
||||||
|
|
||||||
|
/** 드래그 이동 가능 */
|
||||||
|
draggable?: boolean;
|
||||||
|
|
||||||
|
/** 리사이즈 가능 */
|
||||||
|
resizable?: boolean;
|
||||||
|
|
||||||
|
/** 행 높이 (px) */
|
||||||
|
rowHeight?: number;
|
||||||
|
|
||||||
|
/** 헤더 높이 (px) */
|
||||||
|
headerHeight?: number;
|
||||||
|
|
||||||
|
/** 리소스 컬럼 너비 (px) */
|
||||||
|
resourceColumnWidth?: number;
|
||||||
|
|
||||||
|
/** 셀 너비 (px, 줌 레벨별) */
|
||||||
|
cellWidth?: {
|
||||||
|
day?: number;
|
||||||
|
week?: number;
|
||||||
|
month?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 충돌 표시 여부 */
|
||||||
|
showConflicts?: boolean;
|
||||||
|
|
||||||
|
/** 진행률 바 표시 여부 */
|
||||||
|
showProgress?: boolean;
|
||||||
|
|
||||||
|
/** 오늘 표시선 */
|
||||||
|
showTodayLine?: boolean;
|
||||||
|
|
||||||
|
/** 상태별 색상 */
|
||||||
|
statusColors?: {
|
||||||
|
planned?: string;
|
||||||
|
in_progress?: string;
|
||||||
|
completed?: string;
|
||||||
|
delayed?: string;
|
||||||
|
cancelled?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 툴바 표시 여부 */
|
||||||
|
showToolbar?: boolean;
|
||||||
|
|
||||||
|
/** 줌 레벨 변경 버튼 표시 */
|
||||||
|
showZoomControls?: boolean;
|
||||||
|
|
||||||
|
/** 네비게이션 버튼 표시 */
|
||||||
|
showNavigation?: boolean;
|
||||||
|
|
||||||
|
/** 추가 버튼 표시 */
|
||||||
|
showAddButton?: boolean;
|
||||||
|
|
||||||
|
/** 높이 (px 또는 auto) */
|
||||||
|
height?: number | string;
|
||||||
|
|
||||||
|
/** 최대 높이 */
|
||||||
|
maxHeight?: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드래그 이벤트
|
||||||
|
*/
|
||||||
|
export interface DragEvent {
|
||||||
|
/** 스케줄 ID */
|
||||||
|
scheduleId: string;
|
||||||
|
/** 새로운 시작일 */
|
||||||
|
newStartDate: string;
|
||||||
|
/** 새로운 종료일 */
|
||||||
|
newEndDate: string;
|
||||||
|
/** 새로운 리소스 ID (리소스 간 이동 시) */
|
||||||
|
newResourceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리사이즈 이벤트
|
||||||
|
*/
|
||||||
|
export interface ResizeEvent {
|
||||||
|
/** 스케줄 ID */
|
||||||
|
scheduleId: string;
|
||||||
|
/** 새로운 시작일 */
|
||||||
|
newStartDate: string;
|
||||||
|
/** 새로운 종료일 */
|
||||||
|
newEndDate: string;
|
||||||
|
/** 리사이즈 방향 */
|
||||||
|
direction: "start" | "end";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클릭 이벤트
|
||||||
|
*/
|
||||||
|
export interface ScheduleClickEvent {
|
||||||
|
/** 스케줄 항목 */
|
||||||
|
schedule: ScheduleItem;
|
||||||
|
/** 리소스 */
|
||||||
|
resource: Resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 셀 클릭 이벤트
|
||||||
|
*/
|
||||||
|
export interface CellClickEvent {
|
||||||
|
/** 리소스 ID */
|
||||||
|
resourceId: string;
|
||||||
|
/** 날짜 */
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TimelineSchedulerComponent Props
|
||||||
|
*/
|
||||||
|
export interface TimelineSchedulerComponentProps {
|
||||||
|
/** 컴포넌트 설정 */
|
||||||
|
config: TimelineSchedulerConfig;
|
||||||
|
|
||||||
|
/** 디자인 모드 여부 */
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
|
||||||
|
/** 폼 데이터 */
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
|
||||||
|
/** 외부 스케줄 데이터 */
|
||||||
|
externalSchedules?: ScheduleItem[];
|
||||||
|
|
||||||
|
/** 외부 리소스 데이터 */
|
||||||
|
externalResources?: Resource[];
|
||||||
|
|
||||||
|
/** 로딩 상태 */
|
||||||
|
isLoading?: boolean;
|
||||||
|
|
||||||
|
/** 에러 */
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
/** 컴포넌트 ID */
|
||||||
|
componentId?: string;
|
||||||
|
|
||||||
|
/** 드래그 완료 이벤트 */
|
||||||
|
onDragEnd?: (event: DragEvent) => void;
|
||||||
|
|
||||||
|
/** 리사이즈 완료 이벤트 */
|
||||||
|
onResizeEnd?: (event: ResizeEvent) => void;
|
||||||
|
|
||||||
|
/** 스케줄 클릭 이벤트 */
|
||||||
|
onScheduleClick?: (event: ScheduleClickEvent) => void;
|
||||||
|
|
||||||
|
/** 빈 셀 클릭 이벤트 */
|
||||||
|
onCellClick?: (event: CellClickEvent) => void;
|
||||||
|
|
||||||
|
/** 스케줄 추가 이벤트 */
|
||||||
|
onAddSchedule?: (resourceId: string, date: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useTimelineData 훅 반환 타입
|
||||||
|
*/
|
||||||
|
export interface UseTimelineDataResult {
|
||||||
|
/** 스케줄 목록 */
|
||||||
|
schedules: ScheduleItem[];
|
||||||
|
|
||||||
|
/** 리소스 목록 */
|
||||||
|
resources: Resource[];
|
||||||
|
|
||||||
|
/** 로딩 상태 */
|
||||||
|
isLoading: boolean;
|
||||||
|
|
||||||
|
/** 에러 */
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
/** 현재 줌 레벨 */
|
||||||
|
zoomLevel: ZoomLevel;
|
||||||
|
|
||||||
|
/** 줌 레벨 변경 */
|
||||||
|
setZoomLevel: (level: ZoomLevel) => void;
|
||||||
|
|
||||||
|
/** 현재 표시 시작일 */
|
||||||
|
viewStartDate: Date;
|
||||||
|
|
||||||
|
/** 현재 표시 종료일 */
|
||||||
|
viewEndDate: Date;
|
||||||
|
|
||||||
|
/** 이전으로 이동 */
|
||||||
|
goToPrevious: () => void;
|
||||||
|
|
||||||
|
/** 다음으로 이동 */
|
||||||
|
goToNext: () => void;
|
||||||
|
|
||||||
|
/** 오늘로 이동 */
|
||||||
|
goToToday: () => void;
|
||||||
|
|
||||||
|
/** 특정 날짜로 이동 */
|
||||||
|
goToDate: (date: Date) => void;
|
||||||
|
|
||||||
|
/** 스케줄 업데이트 */
|
||||||
|
updateSchedule: (id: string, updates: Partial<ScheduleItem>) => Promise<void>;
|
||||||
|
|
||||||
|
/** 스케줄 추가 */
|
||||||
|
addSchedule: (schedule: Omit<ScheduleItem, "id">) => Promise<void>;
|
||||||
|
|
||||||
|
/** 스케줄 삭제 */
|
||||||
|
deleteSchedule: (id: string) => Promise<void>;
|
||||||
|
|
||||||
|
/** 데이터 새로고침 */
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜 셀 정보
|
||||||
|
*/
|
||||||
|
export interface DateCell {
|
||||||
|
/** 날짜 */
|
||||||
|
date: Date;
|
||||||
|
/** 표시 라벨 */
|
||||||
|
label: string;
|
||||||
|
/** 오늘 여부 */
|
||||||
|
isToday: boolean;
|
||||||
|
/** 주말 여부 */
|
||||||
|
isWeekend: boolean;
|
||||||
|
/** 월 첫째날 여부 */
|
||||||
|
isMonthStart: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 바 위치 정보
|
||||||
|
*/
|
||||||
|
export interface ScheduleBarPosition {
|
||||||
|
/** 왼쪽 오프셋 (px) */
|
||||||
|
left: number;
|
||||||
|
/** 너비 (px) */
|
||||||
|
width: number;
|
||||||
|
/** 상단 오프셋 (px) */
|
||||||
|
top: number;
|
||||||
|
/** 높이 (px) */
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue