docs: v2-timeline-scheduler 구현 완료 및 상태 업데이트
- v2-timeline-scheduler의 구현 상태를 체크리스트에 반영하였으며, 관련 문서화 작업을 완료하였습니다. - 각 구성 요소의 구현 완료 상태를 명시하고, 향후 작업 계획을 업데이트하였습니다. - 타임라인 스케줄러 컴포넌트를 레지스트리에 추가하여 통합하였습니다.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user