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

@@ -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>
);
}