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] 레지스트리 등록
- [x] 문서화 (README.md) - [x] 문서화 (README.md)
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30) #### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30, 업데이트: 2026-03-13)
- [x] 타입 정의 완료 - [x] 타입 정의 완료
- [x] 기본 구조 생성 - [x] 기본 구조 생성
@ -539,12 +539,16 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul
- [x] TimelineGrid (배경) - [x] TimelineGrid (배경)
- [x] ResourceColumn (리소스) - [x] ResourceColumn (리소스)
- [x] ScheduleBar 기본 렌더링 - [x] ScheduleBar 기본 렌더링
- [x] 드래그 이동 (기본) - [x] 드래그 이동 (실제 로직: deltaX → 날짜 계산 → API 저장 → toast)
- [x] 리사이즈 (기본) - [x] 리사이즈 (실제 로직: 시작/종료 핸들 → 기간 변경 → API 저장 → toast)
- [x] 줌 레벨 전환 - [x] 줌 레벨 전환
- [x] 날짜 네비게이션 - [x] 날짜 네비게이션
- [ ] 충돌 감지 (향후) - [x] 충돌 감지 (같은 리소스 겹침 → ring-destructive + AlertTriangle)
- [ ] 가상 스크롤 (향후) - [x] 마일스톤 표시 (시작일 = 종료일 → 다이아몬드 마커)
- [x] 범례 표시 (TimelineLegend: 상태별 색상 + 마일스톤 + 충돌)
- [x] 반응형 공통 CSS 적용 (text-[10px] sm:text-sm 패턴)
- [x] staticFilters 지원 (커스텀 테이블 필터링)
- [ ] 가상 스크롤 (향후 - 대용량 100+ 리소스)
- [x] 설정 패널 구현 - [x] 설정 패널 구현
- [x] API 연동 - [x] API 연동
- [x] 레지스트리 등록 - [x] 레지스트리 등록

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useCallback, useMemo, useRef, useState } from "react"; import React, { useCallback, useMemo, useRef } from "react";
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@ -11,17 +11,16 @@ import {
ZoomOut, ZoomOut,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { toast } from "sonner";
import { import {
TimelineSchedulerComponentProps, TimelineSchedulerComponentProps,
ScheduleItem, ScheduleItem,
ZoomLevel, ZoomLevel,
DragEvent,
ResizeEvent,
} from "./types"; } from "./types";
import { useTimelineData } from "./hooks/useTimelineData"; import { useTimelineData } from "./hooks/useTimelineData";
import { TimelineHeader, ResourceRow } from "./components"; import { TimelineHeader, ResourceRow, TimelineLegend } from "./components";
import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config"; import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config";
import { detectConflicts, addDaysToDateString } from "./utils/conflictDetection";
/** /**
* v2-timeline-scheduler * v2-timeline-scheduler
@ -45,19 +44,6 @@ export function TimelineSchedulerComponent({
}: TimelineSchedulerComponentProps) { }: TimelineSchedulerComponentProps) {
const containerRef = useRef<HTMLDivElement>(null); 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 { const {
schedules, schedules,
@ -78,53 +64,43 @@ export function TimelineSchedulerComponent({
const error = externalError ?? hookError; const error = externalError ?? hookError;
// 설정값 // 설정값
const rowHeight = config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!; const rowHeight =
const headerHeight = config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!; config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!;
const headerHeight =
config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!;
const resourceColumnWidth = const resourceColumnWidth =
config.resourceColumnWidth || defaultTimelineSchedulerConfig.resourceColumnWidth!; config.resourceColumnWidth ||
const cellWidthConfig = config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!; defaultTimelineSchedulerConfig.resourceColumnWidth!;
const cellWidthConfig =
config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!;
const cellWidth = cellWidthConfig[zoomLevel] || 60; const cellWidth = cellWidthConfig[zoomLevel] || 60;
// 리소스가 없으면 스케줄의 resourceId로 자동 생성 // 리소스 자동 생성 (리소스 테이블 미설정 시 스케줄 데이터에서 추출)
const effectiveResources = useMemo(() => { const effectiveResources = useMemo(() => {
if (resources.length > 0) { if (resources.length > 0) return resources;
return resources;
}
// 스케줄에서 고유한 resourceId 추출하여 자동 리소스 생성
const uniqueResourceIds = new Set<string>(); const uniqueResourceIds = new Set<string>();
schedules.forEach((schedule) => { schedules.forEach((s) => {
if (schedule.resourceId) { if (s.resourceId) uniqueResourceIds.add(s.resourceId);
uniqueResourceIds.add(schedule.resourceId);
}
}); });
return Array.from(uniqueResourceIds).map((id) => ({ return Array.from(uniqueResourceIds).map((id) => ({ id, name: id }));
id,
name: id, // resourceId를 이름으로 사용
}));
}, [resources, schedules]); }, [resources, schedules]);
// 리소스별 스케줄 그룹화 // 리소스별 스케줄 그룹화
const schedulesByResource = useMemo(() => { const schedulesByResource = useMemo(() => {
const grouped = new Map<string, ScheduleItem[]>(); const grouped = new Map<string, ScheduleItem[]>();
effectiveResources.forEach((resource) => { effectiveResources.forEach((r) => grouped.set(r.id, []));
grouped.set(resource.id, []);
});
schedules.forEach((schedule) => { schedules.forEach((schedule) => {
const list = grouped.get(schedule.resourceId); const list = grouped.get(schedule.resourceId);
if (list) { if (list) {
list.push(schedule); list.push(schedule);
} else { } else {
// 리소스가 없는 스케줄은 첫 번째 리소스에 할당
const firstResource = effectiveResources[0]; const firstResource = effectiveResources[0];
if (firstResource) { if (firstResource) {
const firstList = grouped.get(firstResource.id); grouped.get(firstResource.id)?.push(schedule);
if (firstList) {
firstList.push(schedule);
}
} }
} }
}); });
@ -132,27 +108,31 @@ export function TimelineSchedulerComponent({
return grouped; return grouped;
}, [schedules, effectiveResources]); }, [schedules, effectiveResources]);
// 줌 레벨 변경 // ────────── 충돌 감지 ──────────
const conflictIds = useMemo(() => {
if (config.showConflicts === false) return new Set<string>();
return detectConflicts(schedules);
}, [schedules, config.showConflicts]);
// ────────── 줌 레벨 변경 ──────────
const handleZoomIn = useCallback(() => { const handleZoomIn = useCallback(() => {
const levels: ZoomLevel[] = ["month", "week", "day"]; const levels: ZoomLevel[] = ["month", "week", "day"];
const currentIdx = levels.indexOf(zoomLevel); const idx = levels.indexOf(zoomLevel);
if (currentIdx < levels.length - 1) { if (idx < levels.length - 1) setZoomLevel(levels[idx + 1]);
setZoomLevel(levels[currentIdx + 1]);
}
}, [zoomLevel, setZoomLevel]); }, [zoomLevel, setZoomLevel]);
const handleZoomOut = useCallback(() => { const handleZoomOut = useCallback(() => {
const levels: ZoomLevel[] = ["month", "week", "day"]; const levels: ZoomLevel[] = ["month", "week", "day"];
const currentIdx = levels.indexOf(zoomLevel); const idx = levels.indexOf(zoomLevel);
if (currentIdx > 0) { if (idx > 0) setZoomLevel(levels[idx - 1]);
setZoomLevel(levels[currentIdx - 1]);
}
}, [zoomLevel, setZoomLevel]); }, [zoomLevel, setZoomLevel]);
// 스케줄 클릭 핸들러 // ────────── 스케줄 클릭 ──────────
const handleScheduleClick = useCallback( const handleScheduleClick = useCallback(
(schedule: ScheduleItem) => { (schedule: ScheduleItem) => {
const resource = effectiveResources.find((r) => r.id === schedule.resourceId); const resource = effectiveResources.find(
(r) => r.id === schedule.resourceId
);
if (resource && onScheduleClick) { if (resource && onScheduleClick) {
onScheduleClick({ schedule, resource }); onScheduleClick({ schedule, resource });
} }
@ -160,7 +140,7 @@ export function TimelineSchedulerComponent({
[effectiveResources, onScheduleClick] [effectiveResources, onScheduleClick]
); );
// 빈 셀 클릭 핸들러 // ────────── 빈 셀 클릭 ──────────
const handleCellClick = useCallback( const handleCellClick = useCallback(
(resourceId: string, date: Date) => { (resourceId: string, date: Date) => {
if (onCellClick) { if (onCellClick) {
@ -173,47 +153,111 @@ export function TimelineSchedulerComponent({
[onCellClick] [onCellClick]
); );
// 드래그 시작 // ────────── 드래그 완료 (핵심 로직) ──────────
const handleDragStart = useCallback( const handleDragComplete = useCallback(
(schedule: ScheduleItem, e: React.MouseEvent) => { async (schedule: ScheduleItem, deltaX: number) => {
setDragState({ // 줌 레벨에 따라 1셀당 일수가 달라짐
schedule, let daysPerCell = 1;
startX: e.clientX, if (zoomLevel === "week") daysPerCell = 7;
startY: e.clientY, 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(() => { const handleResizeComplete = useCallback(
if (dragState) { async (
// TODO: 드래그 결과 계산 및 업데이트 schedule: ScheduleItem,
setDragState(null); direction: "start" | "end",
} deltaX: number
}, [dragState]); ) => {
let daysPerCell = 1;
if (zoomLevel === "week") daysPerCell = 7;
if (zoomLevel === "month") daysPerCell = 30;
// 리사이즈 시작 const deltaDays = Math.round((deltaX / cellWidth) * daysPerCell);
const handleResizeStart = useCallback( if (deltaDays === 0) return;
(schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => {
setResizeState({ let newStartDate = schedule.startDate;
schedule, let newEndDate = schedule.endDate;
direction,
startX: e.clientX, 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(() => { const handleAddClick = useCallback(() => {
if (onAddSchedule && effectiveResources.length > 0) { if (onAddSchedule && effectiveResources.length > 0) {
onAddSchedule( onAddSchedule(
@ -223,7 +267,13 @@ export function TimelineSchedulerComponent({
} }
}, [onAddSchedule, effectiveResources]); }, [onAddSchedule, effectiveResources]);
// 디자인 모드 플레이스홀더 // ────────── 하단 영역 높이 계산 (툴바 + 범례) ──────────
const showToolbar = config.showToolbar !== false;
const showLegend = config.showLegend !== false;
const toolbarHeight = showToolbar ? 36 : 0;
const legendHeight = showLegend ? 28 : 0;
// ────────── 디자인 모드 플레이스홀더 ──────────
if (isDesignMode) { if (isDesignMode) {
return ( 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"> <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) { if (isLoading) {
return ( return (
<div <div
@ -255,7 +305,7 @@ export function TimelineSchedulerComponent({
); );
} }
// 에러 상태 // ────────── 에러 상태 ──────────
if (error) { if (error) {
return ( return (
<div <div
@ -270,7 +320,7 @@ export function TimelineSchedulerComponent({
); );
} }
// 스케줄 데이터 없음 // ────────── 데이터 없음 ──────────
if (schedules.length === 0) { if (schedules.length === 0) {
return ( return (
<div <div
@ -279,9 +329,12 @@ export function TimelineSchedulerComponent({
> >
<div className="text-center text-muted-foreground"> <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" /> <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"> <p className="mt-1.5 max-w-[200px] text-[10px] sm:mt-2 sm:text-xs">
,<br /> ,
<br />
</p> </p>
</div> </div>
@ -289,18 +342,19 @@ export function TimelineSchedulerComponent({
); );
} }
// ────────── 메인 렌더링 ──────────
return ( return (
<div <div
ref={containerRef} 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={{ style={{
height: config.height || 500, height: config.height || 500,
maxHeight: config.maxHeight, maxHeight: config.maxHeight,
}} }}
> >
{/* 툴바 */} {/* 툴바 */}
{config.showToolbar !== false && ( {showToolbar && (
<div className="flex items-center justify-between border-b bg-muted/30 px-2 py-1.5 sm:px-3 sm:py-2"> <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"> <div className="flex items-center gap-0.5 sm:gap-1">
{config.showNavigation !== false && ( {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"> <span className="ml-1 text-[10px] text-muted-foreground sm:ml-2 sm:text-sm">
{viewStartDate.getFullYear()} {viewStartDate.getMonth() + 1}{" "} {viewStartDate.getFullYear()} {viewStartDate.getMonth() + 1}{" "}
{viewStartDate.getDate()} ~{" "} {viewStartDate.getDate()} ~ {viewEndDate.getMonth() + 1}{" "}
{viewEndDate.getMonth() + 1} {viewEndDate.getDate()} {viewEndDate.getDate()}
</span> </span>
</div> </div>
{/* 오른쪽 컨트롤 */} {/* 오른쪽 컨트롤 */}
<div className="flex items-center gap-1 sm:gap-2"> <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 && ( {config.showZoomControls !== false && (
<div className="flex items-center gap-0.5 sm:gap-1"> <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" /> <ZoomOut className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</Button> </Button>
<span className="min-w-[20px] text-center text-[10px] text-muted-foreground sm:min-w-[24px] sm:text-xs"> <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> </span>
<Button <Button
variant="ghost" variant="ghost"
@ -385,15 +449,8 @@ export function TimelineSchedulerComponent({
</div> </div>
)} )}
{/* 타임라인 본문 */} {/* 타임라인 본문 (스크롤 영역) */}
<div <div className="min-h-0 flex-1 overflow-auto">
className="overflow-auto"
style={{
height: config.showToolbar !== false
? `calc(100% - 48px)`
: "100%",
}}
>
<div className="min-w-max"> <div className="min-w-max">
{/* 헤더 */} {/* 헤더 */}
<TimelineHeader <TimelineHeader
@ -420,17 +477,23 @@ export function TimelineSchedulerComponent({
cellWidth={cellWidth} cellWidth={cellWidth}
resourceColumnWidth={resourceColumnWidth} resourceColumnWidth={resourceColumnWidth}
config={config} config={config}
conflictIds={conflictIds}
onScheduleClick={handleScheduleClick} onScheduleClick={handleScheduleClick}
onCellClick={handleCellClick} onCellClick={handleCellClick}
onDragStart={handleDragStart} onDragComplete={handleDragComplete}
onDragEnd={handleDragEnd} onResizeComplete={handleResizeComplete}
onResizeStart={handleResizeStart}
onResizeEnd={handleResizeEnd}
/> />
))} ))}
</div> </div>
</div> </div>
</div> </div>
{/* 범례 */}
{showLegend && (
<div className="shrink-0">
<TimelineLegend config={config} />
</div>
)}
</div> </div>
); );
} }

View File

@ -2,54 +2,44 @@
import React, { useMemo } from "react"; import React, { useMemo } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Resource, ScheduleItem, ZoomLevel, TimelineSchedulerConfig } from "../types"; import {
Resource,
ScheduleItem,
ZoomLevel,
TimelineSchedulerConfig,
} from "../types";
import { ScheduleBar } from "./ScheduleBar"; import { ScheduleBar } from "./ScheduleBar";
interface ResourceRowProps { interface ResourceRowProps {
/** 리소스 */
resource: Resource; resource: Resource;
/** 해당 리소스의 스케줄 목록 */
schedules: ScheduleItem[]; schedules: ScheduleItem[];
/** 시작 날짜 */
startDate: Date; startDate: Date;
/** 종료 날짜 */
endDate: Date; endDate: Date;
/** 줌 레벨 */
zoomLevel: ZoomLevel; zoomLevel: ZoomLevel;
/** 행 높이 */
rowHeight: number; rowHeight: number;
/** 셀 너비 */
cellWidth: number; cellWidth: number;
/** 리소스 컬럼 너비 */
resourceColumnWidth: number; resourceColumnWidth: number;
/** 설정 */
config: TimelineSchedulerConfig; config: TimelineSchedulerConfig;
/** 스케줄 클릭 */ /** 충돌 스케줄 ID 목록 */
conflictIds?: Set<string>;
onScheduleClick?: (schedule: ScheduleItem) => void; onScheduleClick?: (schedule: ScheduleItem) => void;
/** 빈 셀 클릭 */
onCellClick?: (resourceId: string, date: Date) => void; onCellClick?: (resourceId: string, date: Date) => void;
/** 드래그 시작 */ /** 드래그 완료: deltaX(픽셀) 전달 */
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void; onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void;
/** 드래그 종료 */ /** 리사이즈 완료: direction + deltaX(픽셀) 전달 */
onDragEnd?: () => void; onResizeComplete?: (
/** 리사이즈 시작 */ schedule: ScheduleItem,
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void; direction: "start" | "end",
/** 리사이즈 종료 */ deltaX: number
onResizeEnd?: () => void; ) => void;
} }
/**
* ()
*/
const getDaysDiff = (start: Date, end: Date): number => { const getDaysDiff = (start: Date, end: Date): number => {
const startTime = new Date(start).setHours(0, 0, 0, 0); const startTime = new Date(start).setHours(0, 0, 0, 0);
const endTime = new Date(end).setHours(0, 0, 0, 0); const endTime = new Date(end).setHours(0, 0, 0, 0);
return Math.round((endTime - startTime) / (1000 * 60 * 60 * 24)); return Math.round((endTime - startTime) / (1000 * 60 * 60 * 24));
}; };
/**
*
*/
const getCellCount = (startDate: Date, endDate: Date): number => { const getCellCount = (startDate: Date, endDate: Date): number => {
return getDaysDiff(startDate, endDate) + 1; return getDaysDiff(startDate, endDate) + 1;
}; };
@ -64,20 +54,18 @@ export function ResourceRow({
cellWidth, cellWidth,
resourceColumnWidth, resourceColumnWidth,
config, config,
conflictIds,
onScheduleClick, onScheduleClick,
onCellClick, onCellClick,
onDragStart, onDragComplete,
onDragEnd, onResizeComplete,
onResizeStart,
onResizeEnd,
}: ResourceRowProps) { }: ResourceRowProps) {
// 총 셀 개수 const totalCells = useMemo(
const totalCells = useMemo(() => getCellCount(startDate, endDate), [startDate, endDate]); () => getCellCount(startDate, endDate),
[startDate, endDate]
// 총 그리드 너비 );
const gridWidth = totalCells * cellWidth; const gridWidth = totalCells * cellWidth;
// 오늘 날짜
const today = useMemo(() => { const today = useMemo(() => {
const d = new Date(); const d = new Date();
d.setHours(0, 0, 0, 0); d.setHours(0, 0, 0, 0);
@ -92,21 +80,26 @@ export function ResourceRow({
scheduleStart.setHours(0, 0, 0, 0); scheduleStart.setHours(0, 0, 0, 0);
scheduleEnd.setHours(0, 0, 0, 0); scheduleEnd.setHours(0, 0, 0, 0);
// 시작 위치 계산
const startOffset = getDaysDiff(startDate, scheduleStart); const startOffset = getDaysDiff(startDate, scheduleStart);
const left = Math.max(0, startOffset * cellWidth); const left = Math.max(0, startOffset * cellWidth);
// 너비 계산
const durationDays = getDaysDiff(scheduleStart, scheduleEnd) + 1; const durationDays = getDaysDiff(scheduleStart, scheduleEnd) + 1;
const visibleStartOffset = Math.max(0, startOffset); const visibleStartOffset = Math.max(0, startOffset);
const visibleEndOffset = Math.min( const visibleEndOffset = Math.min(
totalCells, totalCells,
startOffset + durationDays 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 { return {
schedule, schedule,
isMilestone,
position: { position: {
left: resourceColumnWidth + left, left: resourceColumnWidth + left,
top: 0, 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>) => { const handleGridClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!onCellClick) return; if (!onCellClick) return;
@ -142,7 +141,9 @@ export function ResourceRow({
style={{ width: resourceColumnWidth }} style={{ width: resourceColumnWidth }}
> >
<div className="truncate"> <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 && ( {resource.group && (
<div className="truncate text-[9px] text-muted-foreground sm:text-xs"> <div className="truncate text-[9px] text-muted-foreground sm:text-xs">
{resource.group} {resource.group}
@ -162,7 +163,8 @@ export function ResourceRow({
{Array.from({ length: totalCells }).map((_, idx) => { {Array.from({ length: totalCells }).map((_, idx) => {
const cellDate = new Date(startDate); const cellDate = new Date(startDate);
cellDate.setDate(cellDate.getDate() + idx); 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 isToday = cellDate.getTime() === today.getTime();
const isMonthStart = cellDate.getDate() === 1; const isMonthStart = cellDate.getDate() === 1;
@ -182,22 +184,22 @@ export function ResourceRow({
</div> </div>
{/* 스케줄 바들 */} {/* 스케줄 바들 */}
{schedulePositions.map(({ schedule, position }) => ( {schedulePositions.map(({ schedule, position, isMilestone }) => (
<ScheduleBar <ScheduleBar
key={schedule.id} key={schedule.id}
schedule={schedule} schedule={schedule}
position={{ position={{
...position, ...position,
left: position.left - resourceColumnWidth, // 상대 위치 left: position.left - resourceColumnWidth,
}} }}
config={config} config={config}
draggable={config.draggable} draggable={config.draggable}
resizable={config.resizable} resizable={config.resizable}
hasConflict={conflictIds?.has(schedule.id) ?? false}
isMilestone={isMilestone}
onClick={() => onScheduleClick?.(schedule)} onClick={() => onScheduleClick?.(schedule)}
onDragStart={onDragStart} onDragComplete={onDragComplete}
onDragEnd={onDragEnd} onResizeComplete={onResizeComplete}
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
/> />
))} ))}
</div> </div>

View File

@ -2,79 +2,99 @@
import React, { useState, useCallback, useRef } from "react"; import React, { useState, useCallback, useRef } from "react";
import { cn } from "@/lib/utils"; 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"; import { statusOptions } from "../config";
interface ScheduleBarProps { interface ScheduleBarProps {
/** 스케줄 항목 */
schedule: ScheduleItem; schedule: ScheduleItem;
/** 위치 정보 */
position: ScheduleBarPosition; position: ScheduleBarPosition;
/** 설정 */
config: TimelineSchedulerConfig; config: TimelineSchedulerConfig;
/** 드래그 가능 여부 */
draggable?: boolean; draggable?: boolean;
/** 리사이즈 가능 여부 */
resizable?: boolean; resizable?: boolean;
/** 클릭 이벤트 */ hasConflict?: boolean;
isMilestone?: boolean;
onClick?: (schedule: ScheduleItem) => void; onClick?: (schedule: ScheduleItem) => void;
/** 드래그 시작 */ /** 드래그 완료 시 deltaX(픽셀) 전달 */
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void; onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void;
/** 드래그 중 */ /** 리사이즈 완료 시 direction과 deltaX(픽셀) 전달 */
onDrag?: (deltaX: number, deltaY: number) => void; onResizeComplete?: (
/** 드래그 종료 */ schedule: ScheduleItem,
onDragEnd?: () => void; direction: "start" | "end",
/** 리사이즈 시작 */ deltaX: number
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void; ) => void;
/** 리사이즈 중 */
onResize?: (deltaX: number, direction: "start" | "end") => void;
/** 리사이즈 종료 */
onResizeEnd?: () => void;
} }
// 드래그/리사이즈 판정 최소 이동 거리 (px)
const MIN_MOVE_THRESHOLD = 5;
export function ScheduleBar({ export function ScheduleBar({
schedule, schedule,
position, position,
config, config,
draggable = true, draggable = true,
resizable = true, resizable = true,
hasConflict = false,
isMilestone = false,
onClick, onClick,
onDragStart, onDragComplete,
onDragEnd, onResizeComplete,
onResizeStart,
onResizeEnd,
}: ScheduleBarProps) { }: ScheduleBarProps) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = 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 barRef = useRef<HTMLDivElement>(null);
const startXRef = useRef(0);
const movedRef = useRef(false);
// 상태에 따른 색상 const statusColor =
const statusColor = schedule.color || schedule.color ||
config.statusColors?.[schedule.status] || config.statusColors?.[schedule.status] ||
statusOptions.find((s) => s.value === schedule.status)?.color || statusOptions.find((s) => s.value === schedule.status)?.color ||
"#3b82f6"; "#3b82f6";
// 진행률 바 너비 const progressWidth =
const progressWidth = config.showProgress && schedule.progress !== undefined config.showProgress && schedule.progress !== undefined
? `${schedule.progress}%` ? `${schedule.progress}%`
: "0%"; : "0%";
// 드래그 시작 핸들러 const isEditable = config.editable !== false;
// ────────── 드래그 핸들러 ──────────
const handleMouseDown = useCallback( const handleMouseDown = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (!draggable || isResizing) return; if (!draggable || isResizing || !isEditable) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
startXRef.current = e.clientX;
movedRef.current = false;
setIsDragging(true); setIsDragging(true);
onDragStart?.(schedule, e); setDragOffset(0);
const handleMouseMove = (moveEvent: MouseEvent) => { 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); setIsDragging(false);
onDragEnd?.(); setDragOffset(0);
if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) {
onDragComplete?.(schedule, finalDelta);
}
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
}; };
@ -82,25 +102,39 @@ export function ScheduleBar({
document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp); 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) => { (direction: "start" | "end", e: React.MouseEvent) => {
if (!resizable) return; if (!resizable || !isEditable) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
startXRef.current = e.clientX;
movedRef.current = false;
setIsResizing(true); setIsResizing(true);
onResizeStart?.(schedule, direction, e); setResizeOffset(0);
setResizeDir(direction);
const handleMouseMove = (moveEvent: MouseEvent) => { 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); setIsResizing(false);
onResizeEnd?.(); setResizeOffset(0);
if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) {
onResizeComplete?.(schedule, direction, finalDelta);
}
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp); document.removeEventListener("mouseup", handleMouseUp);
}; };
@ -108,19 +142,62 @@ export function ScheduleBar({
document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp); document.addEventListener("mouseup", handleMouseUp);
}, },
[resizable, schedule, onResizeStart, onResizeEnd] [resizable, isEditable, schedule, onResizeComplete]
); );
// 클릭 핸들러 // ────────── 클릭 핸들러 ──────────
const handleClick = useCallback( const handleClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (isDragging || isResizing) return; if (movedRef.current) return;
e.stopPropagation(); e.stopPropagation();
onClick?.(schedule); 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 ( return (
<div <div
ref={barRef} ref={barRef}
@ -128,19 +205,21 @@ export function ScheduleBar({
"absolute cursor-pointer rounded-md shadow-sm transition-shadow", "absolute cursor-pointer rounded-md shadow-sm transition-shadow",
"hover:z-10 hover:shadow-md", "hover:z-10 hover:shadow-md",
isDragging && "z-20 opacity-70 shadow-lg", isDragging && "z-20 opacity-70 shadow-lg",
isResizing && "z-20", isResizing && "z-20 opacity-80",
draggable && "cursor-grab", draggable && isEditable && "cursor-grab",
isDragging && "cursor-grabbing" isDragging && "cursor-grabbing",
hasConflict && "ring-2 ring-destructive ring-offset-1"
)} )}
style={{ style={{
left: position.left, left: visualLeft,
top: position.top + 4, top: position.top + 4,
width: position.width, width: visualWidth,
height: position.height - 8, height: position.height - 8,
backgroundColor: statusColor, backgroundColor: statusColor,
}} }}
onClick={handleClick} onClick={handleClick}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
title={schedule.title}
> >
{/* 진행률 바 */} {/* 진행률 바 */}
{config.showProgress && schedule.progress !== undefined && ( {config.showProgress && schedule.progress !== undefined && (
@ -162,19 +241,26 @@ export function ScheduleBar({
</div> </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 <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" 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 <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" 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> </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 { TimelineHeader } from "./TimelineHeader";
export { ScheduleBar } from "./ScheduleBar"; export { ScheduleBar } from "./ScheduleBar";
export { ResourceRow } from "./ResourceRow"; 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];
}