434 lines
13 KiB
TypeScript
434 lines
13 KiB
TypeScript
"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;
|
|
|
|
// 리소스가 없으면 스케줄의 resourceId로 자동 생성
|
|
const effectiveResources = useMemo(() => {
|
|
if (resources.length > 0) {
|
|
return resources;
|
|
}
|
|
|
|
// 스케줄에서 고유한 resourceId 추출하여 자동 리소스 생성
|
|
const uniqueResourceIds = new Set<string>();
|
|
schedules.forEach((schedule) => {
|
|
if (schedule.resourceId) {
|
|
uniqueResourceIds.add(schedule.resourceId);
|
|
}
|
|
});
|
|
|
|
return Array.from(uniqueResourceIds).map((id) => ({
|
|
id,
|
|
name: id, // resourceId를 이름으로 사용
|
|
}));
|
|
}, [resources, schedules]);
|
|
|
|
// 리소스별 스케줄 그룹화
|
|
const schedulesByResource = useMemo(() => {
|
|
const grouped = new Map<string, ScheduleItem[]>();
|
|
|
|
effectiveResources.forEach((resource) => {
|
|
grouped.set(resource.id, []);
|
|
});
|
|
|
|
schedules.forEach((schedule) => {
|
|
const list = grouped.get(schedule.resourceId);
|
|
if (list) {
|
|
list.push(schedule);
|
|
} else {
|
|
// 리소스가 없는 스케줄은 첫 번째 리소스에 할당
|
|
const firstResource = effectiveResources[0];
|
|
if (firstResource) {
|
|
const firstList = grouped.get(firstResource.id);
|
|
if (firstList) {
|
|
firstList.push(schedule);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return grouped;
|
|
}, [schedules, effectiveResources]);
|
|
|
|
// 줌 레벨 변경
|
|
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 = effectiveResources.find((r) => r.id === schedule.resourceId);
|
|
if (resource && onScheduleClick) {
|
|
onScheduleClick({ schedule, resource });
|
|
}
|
|
},
|
|
[effectiveResources, 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 && effectiveResources.length > 0) {
|
|
onAddSchedule(
|
|
effectiveResources[0].id,
|
|
new Date().toISOString().split("T")[0]
|
|
);
|
|
}
|
|
}, [onAddSchedule, effectiveResources]);
|
|
|
|
// 디자인 모드 플레이스홀더
|
|
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 (effectiveResources.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>
|
|
{effectiveResources.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>
|
|
);
|
|
}
|