ERP-node/frontend/lib/registry/components/v2-timeline-scheduler/components/ScheduleBar.tsx

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