From 6505df855555f1ecf3d299a1954ed2ea7604342d Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 16 Mar 2026 10:40:10 +0900 Subject: [PATCH] feat: enhance v2-timeline-scheduler component functionality - Updated the v2-timeline-scheduler documentation to reflect the latest implementation status and enhancements. - Improved the TimelineSchedulerComponent by integrating conflict detection and milestone rendering features. - Refactored ResourceRow and ScheduleBar components to support new props for handling conflicts and milestones. - Added visual indicators for conflicts and milestones to enhance user experience and clarity in scheduling. These changes aim to improve the functionality and usability of the timeline scheduler within the ERP system. Made-with: Cursor --- .../next-component-development-plan.md | 14 +- .../TimelineSchedulerComponent.tsx | 293 +++++++++++------- .../components/ResourceRow.tsx | 98 +++--- .../components/ScheduleBar.tsx | 198 ++++++++---- .../components/TimelineLegend.tsx | 55 ++++ .../v2-timeline-scheduler/components/index.ts | 1 + .../utils/conflictDetection.ts | 58 ++++ 7 files changed, 493 insertions(+), 224 deletions(-) create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/components/TimelineLegend.tsx create mode 100644 frontend/lib/registry/components/v2-timeline-scheduler/utils/conflictDetection.ts 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]; +}