diff --git a/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md b/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md index 58c8cd3f..84f6c789 100644 --- a/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md +++ b/docs/screen-implementation-guide/00_analysis/next-component-development-plan.md @@ -531,7 +531,7 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul - [x] 레지스트리 등록 - [x] 문서화 (README.md) -#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30) +#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30, 업데이트: 2026-03-13) - [x] 타입 정의 완료 - [x] 기본 구조 생성 @@ -539,12 +539,16 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul - [x] TimelineGrid (배경) - [x] ResourceColumn (리소스) - [x] ScheduleBar 기본 렌더링 -- [x] 드래그 이동 (기본) -- [x] 리사이즈 (기본) +- [x] 드래그 이동 (실제 로직: deltaX → 날짜 계산 → API 저장 → toast) +- [x] 리사이즈 (실제 로직: 시작/종료 핸들 → 기간 변경 → API 저장 → toast) - [x] 줌 레벨 전환 - [x] 날짜 네비게이션 -- [ ] 충돌 감지 (향후) -- [ ] 가상 스크롤 (향후) +- [x] 충돌 감지 (같은 리소스 겹침 → ring-destructive + AlertTriangle) +- [x] 마일스톤 표시 (시작일 = 종료일 → 다이아몬드 마커) +- [x] 범례 표시 (TimelineLegend: 상태별 색상 + 마일스톤 + 충돌) +- [x] 반응형 공통 CSS 적용 (text-[10px] sm:text-sm 패턴) +- [x] staticFilters 지원 (커스텀 테이블 필터링) +- [ ] 가상 스크롤 (향후 - 대용량 100+ 리소스) - [x] 설정 패널 구현 - [x] API 연동 - [x] 레지스트리 등록 diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx index e7da45a6..354869bc 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useMemo, useRef, useState } from "react"; +import React, { useCallback, useMemo, useRef } from "react"; import { ChevronLeft, ChevronRight, @@ -11,17 +11,16 @@ import { ZoomOut, } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +import { toast } from "sonner"; import { TimelineSchedulerComponentProps, ScheduleItem, ZoomLevel, - DragEvent, - ResizeEvent, } from "./types"; import { useTimelineData } from "./hooks/useTimelineData"; -import { TimelineHeader, ResourceRow } from "./components"; +import { TimelineHeader, ResourceRow, TimelineLegend } from "./components"; import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config"; +import { detectConflicts, addDaysToDateString } from "./utils/conflictDetection"; /** * v2-timeline-scheduler 메인 컴포넌트 @@ -45,19 +44,6 @@ export function TimelineSchedulerComponent({ }: TimelineSchedulerComponentProps) { const containerRef = useRef(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, @@ -78,53 +64,43 @@ export function TimelineSchedulerComponent({ const error = externalError ?? hookError; // 설정값 - const rowHeight = config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!; - const headerHeight = config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!; + const rowHeight = + config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!; + const headerHeight = + config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!; const resourceColumnWidth = - config.resourceColumnWidth || defaultTimelineSchedulerConfig.resourceColumnWidth!; - const cellWidthConfig = config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!; + 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; - } + if (resources.length > 0) return resources; - // 스케줄에서 고유한 resourceId 추출하여 자동 리소스 생성 const uniqueResourceIds = new Set(); - schedules.forEach((schedule) => { - if (schedule.resourceId) { - uniqueResourceIds.add(schedule.resourceId); - } + schedules.forEach((s) => { + if (s.resourceId) uniqueResourceIds.add(s.resourceId); }); - return Array.from(uniqueResourceIds).map((id) => ({ - id, - name: id, // resourceId를 이름으로 사용 - })); + return Array.from(uniqueResourceIds).map((id) => ({ id, name: id })); }, [resources, schedules]); // 리소스별 스케줄 그룹화 const schedulesByResource = useMemo(() => { const grouped = new Map(); - effectiveResources.forEach((resource) => { - grouped.set(resource.id, []); - }); + 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) { - const firstList = grouped.get(firstResource.id); - if (firstList) { - firstList.push(schedule); - } + grouped.get(firstResource.id)?.push(schedule); } } }); @@ -132,27 +108,31 @@ export function TimelineSchedulerComponent({ 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 currentIdx = levels.indexOf(zoomLevel); - if (currentIdx < levels.length - 1) { - setZoomLevel(levels[currentIdx + 1]); - } + 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 currentIdx = levels.indexOf(zoomLevel); - if (currentIdx > 0) { - setZoomLevel(levels[currentIdx - 1]); - } + 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); + const resource = effectiveResources.find( + (r) => r.id === schedule.resourceId + ); if (resource && onScheduleClick) { onScheduleClick({ schedule, resource }); } @@ -160,7 +140,7 @@ export function TimelineSchedulerComponent({ [effectiveResources, onScheduleClick] ); - // 빈 셀 클릭 핸들러 + // ────────── 빈 셀 클릭 ────────── const handleCellClick = useCallback( (resourceId: string, date: Date) => { if (onCellClick) { @@ -173,47 +153,111 @@ export function TimelineSchedulerComponent({ [onCellClick] ); - // 드래그 시작 - const handleDragStart = useCallback( - (schedule: ScheduleItem, e: React.MouseEvent) => { - setDragState({ - schedule, - startX: e.clientX, - startY: e.clientY, - }); + // ────────── 드래그 완료 (핵심 로직) ────────── + 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 handleDragEnd = useCallback(() => { - if (dragState) { - // TODO: 드래그 결과 계산 및 업데이트 - setDragState(null); - } - }, [dragState]); + // ────────── 리사이즈 완료 (핵심 로직) ────────── + 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 handleResizeStart = useCallback( - (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => { - setResizeState({ - schedule, - direction, - startX: e.clientX, - }); + 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 handleResizeEnd = useCallback(() => { - if (resizeState) { - // TODO: 리사이즈 결과 계산 및 업데이트 - setResizeState(null); - } - }, [resizeState]); - - // 추가 버튼 클릭 + // ────────── 추가 버튼 클릭 ────────── const handleAddClick = useCallback(() => { if (onAddSchedule && effectiveResources.length > 0) { onAddSchedule( @@ -223,7 +267,13 @@ export function TimelineSchedulerComponent({ } }, [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 (
@@ -240,7 +290,7 @@ export function TimelineSchedulerComponent({ ); } - // 로딩 상태 + // ────────── 로딩 상태 ────────── if (isLoading) { return (
-

스케줄 데이터가 없습니다

+

+ 스케줄 데이터가 없습니다 +

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

@@ -289,18 +342,19 @@ export function TimelineSchedulerComponent({ ); } + // ────────── 메인 렌더링 ────────── return (
{/* 툴바 */} - {config.showToolbar !== false && ( -
+ {showToolbar && ( +
{/* 네비게이션 */}
{config.showNavigation !== false && ( @@ -332,16 +386,23 @@ export function TimelineSchedulerComponent({ )} - {/* 현재 날짜 범위 표시 */} + {/* 날짜 범위 표시 */} {viewStartDate.getFullYear()}년 {viewStartDate.getMonth() + 1}월{" "} - {viewStartDate.getDate()}일 ~{" "} - {viewEndDate.getMonth() + 1}월 {viewEndDate.getDate()}일 + {viewStartDate.getDate()}일 ~ {viewEndDate.getMonth() + 1}월{" "} + {viewEndDate.getDate()}일
{/* 오른쪽 컨트롤 */}
+ {/* 충돌 카운트 표시 */} + {config.showConflicts !== false && conflictIds.size > 0 && ( + + 충돌 {conflictIds.size}건 + + )} + {/* 줌 컨트롤 */} {config.showZoomControls !== false && (
@@ -355,7 +416,10 @@ export function TimelineSchedulerComponent({ - {zoomLevelOptions.find((o) => o.value === zoomLevel)?.label} + { + zoomLevelOptions.find((o) => o.value === zoomLevel) + ?.label + }
); } diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx index 75a465a3..4e248cd6 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/ResourceRow.tsx @@ -2,54 +2,44 @@ import React, { useMemo } from "react"; import { cn } from "@/lib/utils"; -import { Resource, ScheduleItem, ZoomLevel, TimelineSchedulerConfig } from "../types"; +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; - /** 스케줄 클릭 */ + /** 충돌 스케줄 ID 목록 */ + conflictIds?: Set; 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; + /** 드래그 완료: deltaX(픽셀) 전달 */ + onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void; + /** 리사이즈 완료: direction + deltaX(픽셀) 전달 */ + onResizeComplete?: ( + schedule: ScheduleItem, + direction: "start" | "end", + deltaX: number + ) => 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; }; @@ -64,20 +54,18 @@ export function ResourceRow({ cellWidth, resourceColumnWidth, config, + conflictIds, onScheduleClick, onCellClick, - onDragStart, - onDragEnd, - onResizeStart, - onResizeEnd, + onDragComplete, + onResizeComplete, }: ResourceRowProps) { - // 총 셀 개수 - const totalCells = useMemo(() => getCellCount(startDate, endDate), [startDate, endDate]); - - // 총 그리드 너비 + 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); @@ -92,21 +80,26 @@ export function ResourceRow({ 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); + const width = Math.max( + cellWidth, + (visibleEndOffset - visibleStartOffset) * cellWidth + ); + + // 시작일 = 종료일이면 마일스톤 + const isMilestone = schedule.startDate === schedule.endDate; return { schedule, + isMilestone, position: { left: resourceColumnWidth + left, top: 0, @@ -115,9 +108,15 @@ export function ResourceRow({ }, }; }); - }, [schedules, startDate, cellWidth, resourceColumnWidth, rowHeight, totalCells]); + }, [ + schedules, + startDate, + cellWidth, + resourceColumnWidth, + rowHeight, + totalCells, + ]); - // 그리드 셀 클릭 핸들러 const handleGridClick = (e: React.MouseEvent) => { if (!onCellClick) return; @@ -142,7 +141,9 @@ export function ResourceRow({ style={{ width: resourceColumnWidth }} >
-
{resource.name}
+
+ {resource.name} +
{resource.group && (
{resource.group} @@ -162,7 +163,8 @@ export function ResourceRow({ {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 isWeekend = + cellDate.getDay() === 0 || cellDate.getDay() === 6; const isToday = cellDate.getTime() === today.getTime(); const isMonthStart = cellDate.getDate() === 1; @@ -182,22 +184,22 @@ export function ResourceRow({
{/* 스케줄 바들 */} - {schedulePositions.map(({ schedule, position }) => ( + {schedulePositions.map(({ schedule, position, isMilestone }) => ( onScheduleClick?.(schedule)} - onDragStart={onDragStart} - onDragEnd={onDragEnd} - onResizeStart={onResizeStart} - onResizeEnd={onResizeEnd} + onDragComplete={onDragComplete} + onResizeComplete={onResizeComplete} /> ))}
diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx index d547fafc..678dc3ac 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx @@ -2,79 +2,99 @@ import React, { useState, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; -import { ScheduleItem, ScheduleBarPosition, TimelineSchedulerConfig } from "../types"; +import { AlertTriangle } from "lucide-react"; +import { + ScheduleItem, + ScheduleBarPosition, + TimelineSchedulerConfig, +} from "../types"; import { statusOptions } from "../config"; interface ScheduleBarProps { - /** 스케줄 항목 */ schedule: ScheduleItem; - /** 위치 정보 */ position: ScheduleBarPosition; - /** 설정 */ config: TimelineSchedulerConfig; - /** 드래그 가능 여부 */ draggable?: boolean; - /** 리사이즈 가능 여부 */ resizable?: boolean; - /** 클릭 이벤트 */ + hasConflict?: boolean; + isMilestone?: 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; + /** 드래그 완료 시 deltaX(픽셀) 전달 */ + onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void; + /** 리사이즈 완료 시 direction과 deltaX(픽셀) 전달 */ + onResizeComplete?: ( + schedule: ScheduleItem, + direction: "start" | "end", + deltaX: number + ) => void; } +// 드래그/리사이즈 판정 최소 이동 거리 (px) +const MIN_MOVE_THRESHOLD = 5; + export function ScheduleBar({ schedule, position, config, draggable = true, resizable = true, + hasConflict = false, + isMilestone = false, onClick, - onDragStart, - onDragEnd, - onResizeStart, - onResizeEnd, + onDragComplete, + onResizeComplete, }: ScheduleBarProps) { const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); + const [dragOffset, setDragOffset] = useState(0); + const [resizeOffset, setResizeOffset] = useState(0); + const [resizeDir, setResizeDir] = useState<"start" | "end">("end"); const barRef = useRef(null); + const startXRef = useRef(0); + const movedRef = useRef(false); - // 상태에 따른 색상 - const statusColor = schedule.color || + 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 progressWidth = + config.showProgress && schedule.progress !== undefined + ? `${schedule.progress}%` + : "0%"; - // 드래그 시작 핸들러 + const isEditable = config.editable !== false; + + // ────────── 드래그 핸들러 ────────── const handleMouseDown = useCallback( (e: React.MouseEvent) => { - if (!draggable || isResizing) return; + if (!draggable || isResizing || !isEditable) return; e.preventDefault(); e.stopPropagation(); + + startXRef.current = e.clientX; + movedRef.current = false; setIsDragging(true); - onDragStart?.(schedule, e); + setDragOffset(0); const handleMouseMove = (moveEvent: MouseEvent) => { - // 드래그 중 로직은 부모에서 처리 + const delta = moveEvent.clientX - startXRef.current; + if (Math.abs(delta) > MIN_MOVE_THRESHOLD) { + movedRef.current = true; + } + setDragOffset(delta); }; - const handleMouseUp = () => { + const handleMouseUp = (upEvent: MouseEvent) => { + const finalDelta = upEvent.clientX - startXRef.current; setIsDragging(false); - onDragEnd?.(); + setDragOffset(0); + + if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) { + onDragComplete?.(schedule, finalDelta); + } + document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; @@ -82,25 +102,39 @@ export function ScheduleBar({ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [draggable, isResizing, schedule, onDragStart, onDragEnd] + [draggable, isResizing, isEditable, schedule, onDragComplete] ); - // 리사이즈 시작 핸들러 - const handleResizeStart = useCallback( + // ────────── 리사이즈 핸들러 ────────── + const handleResizeMouseDown = useCallback( (direction: "start" | "end", e: React.MouseEvent) => { - if (!resizable) return; + if (!resizable || !isEditable) return; e.preventDefault(); e.stopPropagation(); + + startXRef.current = e.clientX; + movedRef.current = false; setIsResizing(true); - onResizeStart?.(schedule, direction, e); + setResizeOffset(0); + setResizeDir(direction); const handleMouseMove = (moveEvent: MouseEvent) => { - // 리사이즈 중 로직은 부모에서 처리 + const delta = moveEvent.clientX - startXRef.current; + if (Math.abs(delta) > MIN_MOVE_THRESHOLD) { + movedRef.current = true; + } + setResizeOffset(delta); }; - const handleMouseUp = () => { + const handleMouseUp = (upEvent: MouseEvent) => { + const finalDelta = upEvent.clientX - startXRef.current; setIsResizing(false); - onResizeEnd?.(); + setResizeOffset(0); + + if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) { + onResizeComplete?.(schedule, direction, finalDelta); + } + document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; @@ -108,19 +142,62 @@ export function ScheduleBar({ document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mouseup", handleMouseUp); }, - [resizable, schedule, onResizeStart, onResizeEnd] + [resizable, isEditable, schedule, onResizeComplete] ); - // 클릭 핸들러 + // ────────── 클릭 핸들러 ────────── const handleClick = useCallback( (e: React.MouseEvent) => { - if (isDragging || isResizing) return; + if (movedRef.current) return; e.stopPropagation(); onClick?.(schedule); }, - [isDragging, isResizing, onClick, schedule] + [onClick, schedule] ); + // ────────── 드래그/리사이즈 중 시각적 위치 계산 ────────── + let visualLeft = position.left; + let visualWidth = position.width; + + if (isDragging) { + visualLeft += dragOffset; + } + + if (isResizing) { + if (resizeDir === "start") { + visualLeft += resizeOffset; + visualWidth -= resizeOffset; + } else { + visualWidth += resizeOffset; + } + } + + visualWidth = Math.max(10, visualWidth); + + // ────────── 마일스톤 렌더링 (단일 날짜 마커) ────────── + if (isMilestone) { + return ( +
+
+
+ ); + } + + // ────────── 일반 스케줄 바 렌더링 ────────── return (
{/* 진행률 바 */} {config.showProgress && schedule.progress !== undefined && ( @@ -162,19 +241,26 @@ export function ScheduleBar({
)} + {/* 충돌 인디케이터 */} + {hasConflict && ( +
+ +
+ )} + {/* 리사이즈 핸들 - 왼쪽 */} - {resizable && ( + {resizable && isEditable && (
handleResizeStart("start", e)} + onMouseDown={(e) => handleResizeMouseDown("start", e)} /> )} {/* 리사이즈 핸들 - 오른쪽 */} - {resizable && ( + {resizable && isEditable && (
handleResizeStart("end", e)} + onMouseDown={(e) => handleResizeMouseDown("end", e)} /> )}
diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineLegend.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineLegend.tsx new file mode 100644 index 00000000..da70e1b7 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineLegend.tsx @@ -0,0 +1,55 @@ +"use client"; + +import React from "react"; +import { TimelineSchedulerConfig } from "../types"; +import { statusOptions } from "../config"; + +interface TimelineLegendProps { + config: TimelineSchedulerConfig; +} + +export function TimelineLegend({ config }: TimelineLegendProps) { + const colors = config.statusColors || {}; + + return ( +
+ + 범례: + + {statusOptions.map((status) => ( +
+
+ + {status.label} + +
+ ))} + + {/* 마일스톤 범례 */} +
+
+
+
+ + 마일스톤 + +
+ + {/* 충돌 범례 */} + {config.showConflicts && ( +
+
+ + 충돌 + +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts b/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts index 4da03f17..4ac2af4b 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/components/index.ts @@ -1,3 +1,4 @@ export { TimelineHeader } from "./TimelineHeader"; export { ScheduleBar } from "./ScheduleBar"; export { ResourceRow } from "./ResourceRow"; +export { TimelineLegend } from "./TimelineLegend"; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts b/frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts new file mode 100644 index 00000000..98b9fbb1 --- /dev/null +++ b/frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts @@ -0,0 +1,58 @@ +"use client"; + +import { ScheduleItem } from "../types"; + +/** + * 같은 리소스에서 시간이 겹치는 스케줄을 감지 + * @returns 충돌이 있는 스케줄 ID Set + */ +export function detectConflicts(schedules: ScheduleItem[]): Set { + const conflictIds = new Set(); + + // 리소스별로 그룹화 + const byResource = new Map(); + for (const schedule of schedules) { + if (!byResource.has(schedule.resourceId)) { + byResource.set(schedule.resourceId, []); + } + byResource.get(schedule.resourceId)!.push(schedule); + } + + // 리소스별 충돌 검사 + for (const [, resourceSchedules] of byResource) { + if (resourceSchedules.length < 2) continue; + + // 시작일 기준 정렬 + const sorted = [...resourceSchedules].sort( + (a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime() + ); + + for (let i = 0; i < sorted.length; i++) { + const aEnd = new Date(sorted[i].endDate).getTime(); + + for (let j = i + 1; j < sorted.length; j++) { + const bStart = new Date(sorted[j].startDate).getTime(); + + // 정렬되어 있으므로 aStart <= bStart + // 겹치는 조건: aEnd > bStart + if (aEnd > bStart) { + conflictIds.add(sorted[i].id); + conflictIds.add(sorted[j].id); + } else { + break; + } + } + } + } + + return conflictIds; +} + +/** + * 날짜를 일수만큼 이동 + */ +export function addDaysToDateString(dateStr: string, days: number): string { + const date = new Date(dateStr); + date.setDate(date.getDate() + days); + return date.toISOString().split("T")[0]; +}