- 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
209 lines
5.8 KiB
TypeScript
209 lines
5.8 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
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;
|
|
/** 드래그 완료: 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;
|
|
};
|
|
|
|
export function ResourceRow({
|
|
resource,
|
|
schedules,
|
|
startDate,
|
|
endDate,
|
|
zoomLevel,
|
|
rowHeight,
|
|
cellWidth,
|
|
resourceColumnWidth,
|
|
config,
|
|
conflictIds,
|
|
onScheduleClick,
|
|
onCellClick,
|
|
onDragComplete,
|
|
onResizeComplete,
|
|
}: ResourceRowProps) {
|
|
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);
|
|
return d;
|
|
}, []);
|
|
|
|
// 스케줄 바 위치 계산
|
|
const schedulePositions = useMemo(() => {
|
|
return schedules.map((schedule) => {
|
|
const scheduleStart = new Date(schedule.startDate);
|
|
const scheduleEnd = new Date(schedule.endDate);
|
|
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 isMilestone = schedule.startDate === schedule.endDate;
|
|
|
|
return {
|
|
schedule,
|
|
isMilestone,
|
|
position: {
|
|
left: resourceColumnWidth + left,
|
|
top: 0,
|
|
width,
|
|
height: rowHeight,
|
|
},
|
|
};
|
|
});
|
|
}, [
|
|
schedules,
|
|
startDate,
|
|
cellWidth,
|
|
resourceColumnWidth,
|
|
rowHeight,
|
|
totalCells,
|
|
]);
|
|
|
|
const handleGridClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (!onCellClick) return;
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const cellIndex = Math.floor(x / cellWidth);
|
|
|
|
const clickedDate = new Date(startDate);
|
|
clickedDate.setDate(clickedDate.getDate() + cellIndex);
|
|
|
|
onCellClick(resource.id, clickedDate);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="flex border-b hover:bg-muted/20"
|
|
style={{ height: rowHeight }}
|
|
>
|
|
{/* 리소스 컬럼 */}
|
|
<div
|
|
className="sticky left-0 z-10 flex shrink-0 items-center border-r bg-muted/30 px-2 sm:px-3"
|
|
style={{ width: resourceColumnWidth }}
|
|
>
|
|
<div className="truncate">
|
|
<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}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 타임라인 그리드 */}
|
|
<div
|
|
className="relative flex-1"
|
|
style={{ width: gridWidth }}
|
|
onClick={handleGridClick}
|
|
>
|
|
{/* 배경 그리드 */}
|
|
<div className="absolute inset-0 flex">
|
|
{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 isToday = cellDate.getTime() === today.getTime();
|
|
const isMonthStart = cellDate.getDate() === 1;
|
|
|
|
return (
|
|
<div
|
|
key={idx}
|
|
className={cn(
|
|
"h-full border-r",
|
|
isWeekend && "bg-muted/20",
|
|
isToday && "bg-primary/5",
|
|
isMonthStart && "border-l-2 border-l-primary/20"
|
|
)}
|
|
style={{ width: cellWidth }}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 스케줄 바들 */}
|
|
{schedulePositions.map(({ schedule, position, isMilestone }) => (
|
|
<ScheduleBar
|
|
key={schedule.id}
|
|
schedule={schedule}
|
|
position={{
|
|
...position,
|
|
left: position.left - resourceColumnWidth,
|
|
}}
|
|
config={config}
|
|
draggable={config.draggable}
|
|
resizable={config.resizable}
|
|
hasConflict={conflictIds?.has(schedule.id) ?? false}
|
|
isMilestone={isMilestone}
|
|
onClick={() => onScheduleClick?.(schedule)}
|
|
onDragComplete={onDragComplete}
|
|
onResizeComplete={onResizeComplete}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|