269 lines
8.3 KiB
TypeScript
269 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useCallback, useRef } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
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;
|
|
/** 드래그 완료 시 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,
|
|
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 ||
|
|
config.statusColors?.[schedule.status] ||
|
|
statusOptions.find((s) => s.value === schedule.status)?.color ||
|
|
"#3b82f6";
|
|
|
|
const progressWidth =
|
|
config.showProgress && schedule.progress !== undefined
|
|
? `${schedule.progress}%`
|
|
: "0%";
|
|
|
|
const isEditable = config.editable !== false;
|
|
|
|
// ────────── 드래그 핸들러 ──────────
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (!draggable || isResizing || !isEditable) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
startXRef.current = e.clientX;
|
|
movedRef.current = false;
|
|
setIsDragging(true);
|
|
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 = (upEvent: MouseEvent) => {
|
|
const finalDelta = upEvent.clientX - startXRef.current;
|
|
setIsDragging(false);
|
|
setDragOffset(0);
|
|
|
|
if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) {
|
|
onDragComplete?.(schedule, finalDelta);
|
|
}
|
|
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
},
|
|
[draggable, isResizing, isEditable, schedule, onDragComplete]
|
|
);
|
|
|
|
// ────────── 리사이즈 핸들러 ──────────
|
|
const handleResizeMouseDown = useCallback(
|
|
(direction: "start" | "end", e: React.MouseEvent) => {
|
|
if (!resizable || !isEditable) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
startXRef.current = e.clientX;
|
|
movedRef.current = false;
|
|
setIsResizing(true);
|
|
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 = (upEvent: MouseEvent) => {
|
|
const finalDelta = upEvent.clientX - startXRef.current;
|
|
setIsResizing(false);
|
|
setResizeOffset(0);
|
|
|
|
if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) {
|
|
onResizeComplete?.(schedule, direction, finalDelta);
|
|
}
|
|
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
},
|
|
[resizable, isEditable, schedule, onResizeComplete]
|
|
);
|
|
|
|
// ────────── 클릭 핸들러 ──────────
|
|
const handleClick = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (movedRef.current) return;
|
|
e.stopPropagation();
|
|
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}
|
|
className={cn(
|
|
"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 opacity-80",
|
|
draggable && isEditable && "cursor-grab",
|
|
isDragging && "cursor-grabbing",
|
|
hasConflict && "ring-2 ring-destructive ring-offset-1"
|
|
)}
|
|
style={{
|
|
left: visualLeft,
|
|
top: position.top + 4,
|
|
width: visualWidth,
|
|
height: position.height - 8,
|
|
backgroundColor: statusColor,
|
|
}}
|
|
onClick={handleClick}
|
|
onMouseDown={handleMouseDown}
|
|
title={schedule.title}
|
|
>
|
|
{/* 진행률 바 */}
|
|
{config.showProgress && schedule.progress !== undefined && (
|
|
<div
|
|
className="absolute inset-y-0 left-0 rounded-l-md bg-white opacity-30"
|
|
style={{ width: progressWidth }}
|
|
/>
|
|
)}
|
|
|
|
{/* 제목 */}
|
|
<div className="relative z-10 truncate px-1.5 py-0.5 text-[10px] font-medium text-white sm:px-2 sm:py-1 sm:text-xs">
|
|
{schedule.title}
|
|
</div>
|
|
|
|
{/* 진행률 텍스트 */}
|
|
{config.showProgress && schedule.progress !== undefined && (
|
|
<div className="absolute right-1 top-1/2 -translate-y-1/2 text-[8px] font-medium text-white/80 sm:right-2 sm:text-[10px]">
|
|
{schedule.progress}%
|
|
</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 && 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) => handleResizeMouseDown("start", e)}
|
|
/>
|
|
)}
|
|
|
|
{/* 리사이즈 핸들 - 오른쪽 */}
|
|
{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) => handleResizeMouseDown("end", e)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|