"use client"; import React, { useCallback, useMemo, useRef } from "react"; import { ChevronLeft, ChevronRight, Calendar, Plus, Loader2, ZoomIn, ZoomOut, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { TimelineSchedulerComponentProps, ScheduleItem, ZoomLevel, } from "./types"; import { useTimelineData } from "./hooks/useTimelineData"; import { TimelineHeader, ResourceRow, TimelineLegend } from "./components"; import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config"; import { detectConflicts, addDaysToDateString } from "./utils/conflictDetection"; /** * v2-timeline-scheduler 메인 컴포넌트 * * 간트차트 형태의 일정/계획 시각화 및 편집 컴포넌트 */ export function TimelineSchedulerComponent({ config, isDesignMode = false, formData, externalSchedules, externalResources, isLoading: externalLoading, error: externalError, componentId, onDragEnd, onResizeEnd, onScheduleClick, onCellClick, onAddSchedule, }: TimelineSchedulerComponentProps) { const containerRef = useRef(null); // 타임라인 데이터 훅 const { 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 effectiveResources = useMemo(() => { if (resources.length > 0) return resources; const uniqueResourceIds = new Set(); schedules.forEach((s) => { if (s.resourceId) uniqueResourceIds.add(s.resourceId); }); return Array.from(uniqueResourceIds).map((id) => ({ id, name: id })); }, [resources, schedules]); // 리소스별 스케줄 그룹화 const schedulesByResource = useMemo(() => { const grouped = new Map(); effectiveResources.forEach((r) => grouped.set(r.id, [])); schedules.forEach((schedule) => { const list = grouped.get(schedule.resourceId); if (list) { list.push(schedule); } else { const firstResource = effectiveResources[0]; if (firstResource) { grouped.get(firstResource.id)?.push(schedule); } } }); return grouped; }, [schedules, effectiveResources]); // ────────── 충돌 감지 ────────── const conflictIds = useMemo(() => { if (config.showConflicts === false) return new Set(); return detectConflicts(schedules); }, [schedules, config.showConflicts]); // ────────── 줌 레벨 변경 ────────── const handleZoomIn = useCallback(() => { const levels: ZoomLevel[] = ["month", "week", "day"]; const idx = levels.indexOf(zoomLevel); if (idx < levels.length - 1) setZoomLevel(levels[idx + 1]); }, [zoomLevel, setZoomLevel]); const handleZoomOut = useCallback(() => { const levels: ZoomLevel[] = ["month", "week", "day"]; const idx = levels.indexOf(zoomLevel); if (idx > 0) setZoomLevel(levels[idx - 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 handleDragComplete = useCallback( async (schedule: ScheduleItem, deltaX: number) => { // 줌 레벨에 따라 1셀당 일수가 달라짐 let daysPerCell = 1; if (zoomLevel === "week") daysPerCell = 7; if (zoomLevel === "month") daysPerCell = 30; const deltaDays = Math.round((deltaX / cellWidth) * daysPerCell); if (deltaDays === 0) return; const newStartDate = addDaysToDateString(schedule.startDate, deltaDays); const newEndDate = addDaysToDateString(schedule.endDate, deltaDays); try { await updateSchedule(schedule.id, { startDate: newStartDate, endDate: newEndDate, }); // 외부 이벤트 핸들러 호출 onDragEnd?.({ scheduleId: schedule.id, newStartDate, newEndDate, }); toast.success("스케줄 이동 완료", { description: `${schedule.title}: ${newStartDate} ~ ${newEndDate}`, }); } catch (err: any) { toast.error("스케줄 이동 실패", { description: err.message || "잠시 후 다시 시도해주세요", }); } }, [cellWidth, zoomLevel, updateSchedule, onDragEnd] ); // ────────── 리사이즈 완료 (핵심 로직) ────────── const handleResizeComplete = useCallback( async ( schedule: ScheduleItem, direction: "start" | "end", deltaX: number ) => { let daysPerCell = 1; if (zoomLevel === "week") daysPerCell = 7; if (zoomLevel === "month") daysPerCell = 30; const deltaDays = Math.round((deltaX / cellWidth) * daysPerCell); if (deltaDays === 0) return; let newStartDate = schedule.startDate; let newEndDate = schedule.endDate; if (direction === "start") { newStartDate = addDaysToDateString(schedule.startDate, deltaDays); // 시작일이 종료일을 넘지 않도록 if (new Date(newStartDate) >= new Date(newEndDate)) { toast.warning("시작일은 종료일보다 이전이어야 합니다"); return; } } else { newEndDate = addDaysToDateString(schedule.endDate, deltaDays); // 종료일이 시작일보다 앞서지 않도록 if (new Date(newEndDate) <= new Date(newStartDate)) { toast.warning("종료일은 시작일보다 이후여야 합니다"); return; } } try { await updateSchedule(schedule.id, { startDate: newStartDate, endDate: newEndDate, }); onResizeEnd?.({ scheduleId: schedule.id, newStartDate, newEndDate, direction, }); const days = Math.round( (new Date(newEndDate).getTime() - new Date(newStartDate).getTime()) / (1000 * 60 * 60 * 24) ) + 1; toast.success("기간 변경 완료", { description: `${schedule.title}: ${days}일 (${newStartDate} ~ ${newEndDate})`, }); } catch (err: any) { toast.error("기간 변경 실패", { description: err.message || "잠시 후 다시 시도해주세요", }); } }, [cellWidth, zoomLevel, updateSchedule, onResizeEnd] ); // ────────── 추가 버튼 클릭 ────────── const handleAddClick = useCallback(() => { if (onAddSchedule && effectiveResources.length > 0) { onAddSchedule( effectiveResources[0].id, new Date().toISOString().split("T")[0] ); } }, [onAddSchedule, effectiveResources]); // ────────── 하단 영역 높이 계산 (툴바 + 범례) ────────── const showToolbar = config.showToolbar !== false; const showLegend = config.showLegend !== false; const toolbarHeight = showToolbar ? 36 : 0; const legendHeight = showLegend ? 28 : 0; // ────────── 디자인 모드 플레이스홀더 ────────── if (isDesignMode) { return (

타임라인 스케줄러

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

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

오류 발생

{error}

); } // ────────── 데이터 없음 ────────── if (schedules.length === 0) { return (

스케줄 데이터가 없습니다

좌측 테이블에서 품목을 선택하거나,
스케줄 생성 버튼을 눌러 스케줄을 생성하세요

); } // ────────── 메인 렌더링 ────────── return (
{/* 툴바 */} {showToolbar && (
{/* 네비게이션 */}
{config.showNavigation !== false && ( <> )} {/* 날짜 범위 표시 */} {viewStartDate.getFullYear()}년 {viewStartDate.getMonth() + 1}월{" "} {viewStartDate.getDate()}일 ~ {viewEndDate.getMonth() + 1}월{" "} {viewEndDate.getDate()}일
{/* 오른쪽 컨트롤 */}
{/* 충돌 카운트 표시 */} {config.showConflicts !== false && conflictIds.size > 0 && ( 충돌 {conflictIds.size}건 )} {/* 줌 컨트롤 */} {config.showZoomControls !== false && (
{ zoomLevelOptions.find((o) => o.value === zoomLevel) ?.label }
)} {/* 추가 버튼 */} {config.showAddButton !== false && config.editable && ( )}
)} {/* 타임라인 본문 (스크롤 영역) */}
{/* 헤더 */} {/* 리소스 행들 */}
{effectiveResources.map((resource) => ( ))}
{/* 범례 */} {showLegend && (
)}
); }