ERP-node/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx

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>
);
}