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