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
This commit is contained in:
kjs 2026-03-16 10:40:10 +09:00
parent d3e62912e7
commit 6505df8555
7 changed files with 493 additions and 224 deletions

View File

@ -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] 레지스트리 등록

View File

@ -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<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,
@ -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<string>();
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<string, ScheduleItem[]>();
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<string>();
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 (
<div className="flex h-full min-h-[200px] w-full items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/30 bg-muted/10">
@ -240,7 +290,7 @@ export function TimelineSchedulerComponent({
);
}
// 로딩 상태
// ────────── 로딩 상태 ──────────
if (isLoading) {
return (
<div
@ -255,7 +305,7 @@ export function TimelineSchedulerComponent({
);
}
// 에러 상태
// ────────── 에러 상태 ──────────
if (error) {
return (
<div
@ -270,7 +320,7 @@ export function TimelineSchedulerComponent({
);
}
// 스케줄 데이터 없음
// ────────── 데이터 없음 ──────────
if (schedules.length === 0) {
return (
<div
@ -279,9 +329,12 @@ export function TimelineSchedulerComponent({
>
<div className="text-center text-muted-foreground">
<Calendar className="mx-auto mb-2 h-8 w-8 opacity-50 sm:mb-3 sm:h-10 sm:w-10" />
<p className="text-xs font-medium sm:text-sm"> </p>
<p className="text-xs font-medium sm:text-sm">
</p>
<p className="mt-1.5 max-w-[200px] text-[10px] sm:mt-2 sm:text-xs">
,<br />
,
<br />
</p>
</div>
@ -289,18 +342,19 @@ export function TimelineSchedulerComponent({
);
}
// ────────── 메인 렌더링 ──────────
return (
<div
ref={containerRef}
className="w-full overflow-hidden rounded-lg border bg-background"
className="flex w-full flex-col overflow-hidden rounded-lg border bg-background"
style={{
height: config.height || 500,
maxHeight: config.maxHeight,
}}
>
{/* 툴바 */}
{config.showToolbar !== false && (
<div className="flex items-center justify-between border-b bg-muted/30 px-2 py-1.5 sm:px-3 sm:py-2">
{showToolbar && (
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-2 py-1.5 sm:px-3 sm:py-2">
{/* 네비게이션 */}
<div className="flex items-center gap-0.5 sm:gap-1">
{config.showNavigation !== false && (
@ -332,16 +386,23 @@ export function TimelineSchedulerComponent({
</>
)}
{/* 현재 날짜 범위 표시 */}
{/* 날짜 범위 표시 */}
<span className="ml-1 text-[10px] text-muted-foreground sm:ml-2 sm:text-sm">
{viewStartDate.getFullYear()} {viewStartDate.getMonth() + 1}{" "}
{viewStartDate.getDate()} ~{" "}
{viewEndDate.getMonth() + 1} {viewEndDate.getDate()}
{viewStartDate.getDate()} ~ {viewEndDate.getMonth() + 1}{" "}
{viewEndDate.getDate()}
</span>
</div>
{/* 오른쪽 컨트롤 */}
<div className="flex items-center gap-1 sm:gap-2">
{/* 충돌 카운트 표시 */}
{config.showConflicts !== false && conflictIds.size > 0 && (
<span className="rounded-full bg-destructive/10 px-1.5 py-0.5 text-[9px] font-medium text-destructive sm:px-2 sm:text-[10px]">
{conflictIds.size}
</span>
)}
{/* 줌 컨트롤 */}
{config.showZoomControls !== false && (
<div className="flex items-center gap-0.5 sm:gap-1">
@ -355,7 +416,10 @@ export function TimelineSchedulerComponent({
<ZoomOut className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</Button>
<span className="min-w-[20px] text-center text-[10px] text-muted-foreground sm:min-w-[24px] sm:text-xs">
{zoomLevelOptions.find((o) => o.value === zoomLevel)?.label}
{
zoomLevelOptions.find((o) => o.value === zoomLevel)
?.label
}
</span>
<Button
variant="ghost"
@ -385,15 +449,8 @@ export function TimelineSchedulerComponent({
</div>
)}
{/* 타임라인 본문 */}
<div
className="overflow-auto"
style={{
height: config.showToolbar !== false
? `calc(100% - 48px)`
: "100%",
}}
>
{/* 타임라인 본문 (스크롤 영역) */}
<div className="min-h-0 flex-1 overflow-auto">
<div className="min-w-max">
{/* 헤더 */}
<TimelineHeader
@ -420,17 +477,23 @@ export function TimelineSchedulerComponent({
cellWidth={cellWidth}
resourceColumnWidth={resourceColumnWidth}
config={config}
conflictIds={conflictIds}
onScheduleClick={handleScheduleClick}
onCellClick={handleCellClick}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onResizeStart={handleResizeStart}
onResizeEnd={handleResizeEnd}
onDragComplete={handleDragComplete}
onResizeComplete={handleResizeComplete}
/>
))}
</div>
</div>
</div>
{/* 범례 */}
{showLegend && (
<div className="shrink-0">
<TimelineLegend config={config} />
</div>
)}
</div>
);
}

View File

@ -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<string>;
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<HTMLDivElement>) => {
if (!onCellClick) return;
@ -142,7 +141,9 @@ export function ResourceRow({
style={{ width: resourceColumnWidth }}
>
<div className="truncate">
<div className="truncate text-[10px] font-medium sm:text-sm">{resource.name}</div>
<div className="truncate text-[10px] font-medium sm:text-sm">
{resource.name}
</div>
{resource.group && (
<div className="truncate text-[9px] text-muted-foreground sm:text-xs">
{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({
</div>
{/* 스케줄 바들 */}
{schedulePositions.map(({ schedule, position }) => (
{schedulePositions.map(({ schedule, position, isMilestone }) => (
<ScheduleBar
key={schedule.id}
schedule={schedule}
position={{
...position,
left: position.left - resourceColumnWidth, // 상대 위치
left: position.left - resourceColumnWidth,
}}
config={config}
draggable={config.draggable}
resizable={config.resizable}
hasConflict={conflictIds?.has(schedule.id) ?? false}
isMilestone={isMilestone}
onClick={() => onScheduleClick?.(schedule)}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
onDragComplete={onDragComplete}
onResizeComplete={onResizeComplete}
/>
))}
</div>

View File

@ -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<HTMLDivElement>(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 (
<div
ref={barRef}
className="absolute flex cursor-pointer items-center justify-center"
style={{
left: visualLeft + position.width / 2 - 8,
top: position.top + position.height / 2 - 8,
width: 16,
height: 16,
}}
onClick={handleClick}
title={schedule.title}
>
<div
className="h-2.5 w-2.5 rotate-45 shadow-sm transition-transform hover:scale-125 sm:h-3 sm:w-3"
style={{ backgroundColor: statusColor }}
/>
</div>
);
}
// ────────── 일반 스케줄 바 렌더링 ──────────
return (
<div
ref={barRef}
@ -128,19 +205,21 @@ export function ScheduleBar({
"absolute cursor-pointer rounded-md shadow-sm transition-shadow",
"hover:z-10 hover:shadow-md",
isDragging && "z-20 opacity-70 shadow-lg",
isResizing && "z-20",
draggable && "cursor-grab",
isDragging && "cursor-grabbing"
isResizing && "z-20 opacity-80",
draggable && isEditable && "cursor-grab",
isDragging && "cursor-grabbing",
hasConflict && "ring-2 ring-destructive ring-offset-1"
)}
style={{
left: position.left,
left: visualLeft,
top: position.top + 4,
width: position.width,
width: visualWidth,
height: position.height - 8,
backgroundColor: statusColor,
}}
onClick={handleClick}
onMouseDown={handleMouseDown}
title={schedule.title}
>
{/* 진행률 바 */}
{config.showProgress && schedule.progress !== undefined && (
@ -162,19 +241,26 @@ export function ScheduleBar({
</div>
)}
{/* 충돌 인디케이터 */}
{hasConflict && (
<div className="absolute -right-0.5 -top-0.5 sm:-right-1 sm:-top-1">
<AlertTriangle className="h-2.5 w-2.5 fill-destructive text-white sm:h-3 sm:w-3" />
</div>
)}
{/* 리사이즈 핸들 - 왼쪽 */}
{resizable && (
{resizable && isEditable && (
<div
className="absolute bottom-0 left-0 top-0 w-1.5 cursor-ew-resize rounded-l-md hover:bg-white/20 sm:w-2"
onMouseDown={(e) => handleResizeStart("start", e)}
onMouseDown={(e) => handleResizeMouseDown("start", e)}
/>
)}
{/* 리사이즈 핸들 - 오른쪽 */}
{resizable && (
{resizable && isEditable && (
<div
className="absolute bottom-0 right-0 top-0 w-1.5 cursor-ew-resize rounded-r-md hover:bg-white/20 sm:w-2"
onMouseDown={(e) => handleResizeStart("end", e)}
onMouseDown={(e) => handleResizeMouseDown("end", e)}
/>
)}
</div>

View File

@ -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 (
<div className="flex flex-wrap items-center gap-2 border-t bg-muted/20 px-2 py-1 sm:gap-3 sm:px-3 sm:py-1.5">
<span className="text-[10px] font-medium text-muted-foreground sm:text-xs">
:
</span>
{statusOptions.map((status) => (
<div key={status.value} className="flex items-center gap-1">
<div
className="h-2 w-4 rounded-sm sm:h-2.5 sm:w-5"
style={{
backgroundColor:
colors[status.value as keyof typeof colors] || status.color,
}}
/>
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
{status.label}
</span>
</div>
))}
{/* 마일스톤 범례 */}
<div className="flex items-center gap-1">
<div className="flex h-2.5 w-4 items-center justify-center sm:h-3 sm:w-5">
<div className="h-1.5 w-1.5 rotate-45 bg-foreground/60 sm:h-2 sm:w-2" />
</div>
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
</span>
</div>
{/* 충돌 범례 */}
{config.showConflicts && (
<div className="flex items-center gap-1">
<div className="h-2 w-4 rounded-sm ring-1.5 ring-destructive sm:h-2.5 sm:w-5" />
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
</span>
</div>
)}
</div>
);
}

View File

@ -1,3 +1,4 @@
export { TimelineHeader } from "./TimelineHeader";
export { ScheduleBar } from "./ScheduleBar";
export { ResourceRow } from "./ResourceRow";
export { TimelineLegend } from "./TimelineLegend";

View File

@ -0,0 +1,58 @@
"use client";
import { ScheduleItem } from "../types";
/**
*
* @returns ID Set
*/
export function detectConflicts(schedules: ScheduleItem[]): Set<string> {
const conflictIds = new Set<string>();
// 리소스별로 그룹화
const byResource = new Map<string, ScheduleItem[]>();
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];
}