ERP-node/frontend/components/common/TimelineScheduler.tsx

826 lines
29 KiB
TypeScript

"use client";
/**
* TimelineScheduler — 하드코딩 페이지용 공통 타임라인 스케줄러 컴포넌트
*
* 기능:
* - 리소스(설비/품목) 기준 Y축, 날짜 기준 X축
* - 줌 레벨 전환 (일/주/월)
* - 날짜 네비게이션 (이전/다음/오늘)
* - 이벤트 바 드래그 이동
* - 이벤트 바 리사이즈 (좌/우 핸들)
* - 오늘 날짜 빨간 세로선
* - 진행률 바 시각화
* - 마일스톤 (다이아몬드) 표시
* - 상태별 색상 + 범례
* - 충돌 감지 (같은 리소스에서 겹침 시 빨간 테두리)
*/
import React, { useState, useRef, useCallback, useMemo, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
ChevronLeft,
ChevronRight,
CalendarDays,
Loader2,
Diamond,
} from "lucide-react";
import { cn } from "@/lib/utils";
// ─── 타입 정의 ───
export interface TimelineResource {
id: string;
label: string;
subLabel?: string;
}
export interface TimelineEvent {
id: string | number;
resourceId: string;
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
label?: string;
status?: string;
progress?: number; // 0~100
isMilestone?: boolean;
data?: any;
}
export type ZoomLevel = "day" | "week" | "month";
export interface StatusColor {
key: string;
label: string;
bgClass: string; // tailwind gradient class e.g. "from-blue-500 to-blue-600"
}
export interface TimelineSchedulerProps {
resources: TimelineResource[];
events: TimelineEvent[];
/** 타임라인 시작 기준일 (기본: 오늘) */
startDate?: Date;
/** 줌 레벨 (기본: week) */
zoomLevel?: ZoomLevel;
onZoomChange?: (zoom: ZoomLevel) => void;
/** 이벤트 바 클릭 */
onEventClick?: (event: TimelineEvent) => void;
/** 드래그 이동 완료 */
onEventMove?: (eventId: string | number, newStartDate: string, newEndDate: string) => void;
/** 리사이즈 완료 */
onEventResize?: (eventId: string | number, newStartDate: string, newEndDate: string) => void;
/** 상태별 색상 배열 */
statusColors?: StatusColor[];
/** 진행률 바 표시 여부 */
showProgress?: boolean;
/** 마일스톤 표시 여부 */
showMilestones?: boolean;
/** 오늘 세로선 표시 */
showTodayLine?: boolean;
/** 범례 표시 */
showLegend?: boolean;
/** 충돌 감지 */
conflictDetection?: boolean;
/** 로딩 상태 */
loading?: boolean;
/** 데이터 없을 때 메시지 */
emptyMessage?: string;
/** 데이터 없을 때 아이콘 */
emptyIcon?: React.ReactNode;
/** 리소스 열 너비 (px) */
resourceWidth?: number;
/** 행 높이 (px) */
rowHeight?: number;
}
// ─── 기본값 ───
const DEFAULT_STATUS_COLORS: StatusColor[] = [
{ key: "planned", label: "계획", bgClass: "from-blue-500 to-blue-600" },
{ key: "work-order", label: "지시", bgClass: "from-amber-500 to-amber-600" },
{ key: "in-progress", label: "진행", bgClass: "from-emerald-500 to-emerald-600" },
{ key: "completed", label: "완료", bgClass: "from-gray-400 to-gray-500" },
];
const ZOOM_CONFIG: Record<ZoomLevel, { cellWidth: number; spanDays: number; navStep: number }> = {
day: { cellWidth: 60, spanDays: 28, navStep: 7 },
week: { cellWidth: 36, spanDays: 56, navStep: 14 },
month: { cellWidth: 16, spanDays: 90, navStep: 30 },
};
// ─── 유틸리티 함수 ───
/** YYYY-MM-DD 문자열로 변환 */
function toDateStr(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** 날짜 문자열을 Date로 (시간 0시) */
function parseDate(s: string): Date {
const [y, m, d] = s.split("T")[0].split("-").map(Number);
return new Date(y, m - 1, d);
}
/** 두 날짜 사이의 일 수 차이 */
function diffDays(a: Date, b: Date): number {
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
}
/** 날짜에 일 수 더하기 */
function addDays(d: Date, n: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
function isWeekend(d: Date): boolean {
return d.getDay() === 0 || d.getDay() === 6;
}
function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
const DAY_NAMES = ["일", "월", "화", "수", "목", "금", "토"];
const MONTH_NAMES = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"];
// ─── 충돌 감지 ───
function detectConflicts(events: TimelineEvent[]): Set<string | number> {
const conflictIds = new Set<string | number>();
const byResource = new Map<string, TimelineEvent[]>();
for (const ev of events) {
if (ev.isMilestone) continue;
if (!byResource.has(ev.resourceId)) byResource.set(ev.resourceId, []);
byResource.get(ev.resourceId)!.push(ev);
}
for (const [, resEvents] of byResource) {
for (let i = 0; i < resEvents.length; i++) {
for (let j = i + 1; j < resEvents.length; j++) {
const a = resEvents[i];
const b = resEvents[j];
const aStart = parseDate(a.startDate).getTime();
const aEnd = parseDate(a.endDate).getTime();
const bStart = parseDate(b.startDate).getTime();
const bEnd = parseDate(b.endDate).getTime();
if (aStart <= bEnd && bStart <= aEnd) {
conflictIds.add(a.id);
conflictIds.add(b.id);
}
}
}
}
return conflictIds;
}
// ─── 메인 컴포넌트 ───
export default function TimelineScheduler({
resources,
events,
startDate: propStartDate,
zoomLevel: propZoom,
onZoomChange,
onEventClick,
onEventMove,
onEventResize,
statusColors = DEFAULT_STATUS_COLORS,
showProgress = true,
showMilestones = true,
showTodayLine = true,
showLegend = true,
conflictDetection = true,
loading = false,
emptyMessage = "데이터가 없습니다",
emptyIcon,
resourceWidth = 160,
rowHeight = 48,
}: TimelineSchedulerProps) {
// ── 상태 ──
const [zoom, setZoom] = useState<ZoomLevel>(propZoom || "week");
const [baseDate, setBaseDate] = useState<Date>(() => {
const d = propStartDate || new Date();
d.setHours(0, 0, 0, 0);
return d;
});
// 드래그/리사이즈 상태
const [dragState, setDragState] = useState<{
eventId: string | number;
mode: "move" | "resize-left" | "resize-right";
origStartDate: string;
origEndDate: string;
startX: number;
currentOffsetDays: number;
} | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// 줌 레벨 동기화
useEffect(() => {
if (propZoom && propZoom !== zoom) setZoom(propZoom);
}, [propZoom]);
const config = ZOOM_CONFIG[zoom];
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
// 날짜 배열 생성
const dates = useMemo(() => {
const arr: Date[] = [];
for (let i = 0; i < config.spanDays; i++) {
arr.push(addDays(baseDate, i));
}
return arr;
}, [baseDate, config.spanDays]);
const totalWidth = config.cellWidth * config.spanDays;
// 충돌 ID 집합
const conflictIds = useMemo(() => {
return conflictDetection ? detectConflicts(events) : new Set<string | number>();
}, [events, conflictDetection]);
// 리소스별 이벤트 그룹
const eventsByResource = useMemo(() => {
const map = new Map<string, TimelineEvent[]>();
for (const r of resources) map.set(r.id, []);
for (const ev of events) {
if (!map.has(ev.resourceId)) map.set(ev.resourceId, []);
map.get(ev.resourceId)!.push(ev);
}
return map;
}, [resources, events]);
// 같은 리소스 내 겹치는 이벤트들의 행(lane) 계산
const eventLanes = useMemo(() => {
const laneMap = new Map<string | number, number>();
for (const [, resEvents] of eventsByResource) {
// 시작일 기준 정렬
const sorted = [...resEvents].sort(
(a, b) => parseDate(a.startDate).getTime() - parseDate(b.startDate).getTime()
);
const lanes: { endTime: number }[] = [];
for (const ev of sorted) {
if (ev.isMilestone) {
laneMap.set(ev.id, 0);
continue;
}
const evStart = parseDate(ev.startDate).getTime();
const evEnd = parseDate(ev.endDate).getTime();
let placed = false;
for (let l = 0; l < lanes.length; l++) {
if (evStart > lanes[l].endTime) {
lanes[l].endTime = evEnd;
laneMap.set(ev.id, l);
placed = true;
break;
}
}
if (!placed) {
laneMap.set(ev.id, lanes.length);
lanes.push({ endTime: evEnd });
}
}
}
return laneMap;
}, [eventsByResource]);
// 리소스별 최대 lane 수 -> 행 높이 결정
const resourceLaneCounts = useMemo(() => {
const map = new Map<string, number>();
for (const [resId, resEvents] of eventsByResource) {
let maxLane = 0;
for (const ev of resEvents) {
const lane = eventLanes.get(ev.id) || 0;
maxLane = Math.max(maxLane, lane);
}
map.set(resId, resEvents.length > 0 ? maxLane + 1 : 1);
}
return map;
}, [eventsByResource, eventLanes]);
// ── 줌/네비게이션 핸들러 ──
const handleZoom = useCallback(
(z: ZoomLevel) => {
setZoom(z);
onZoomChange?.(z);
},
[onZoomChange]
);
const handleNavPrev = useCallback(() => {
setBaseDate((prev) => addDays(prev, -config.navStep));
}, [config.navStep]);
const handleNavNext = useCallback(() => {
setBaseDate((prev) => addDays(prev, config.navStep));
}, [config.navStep]);
const handleNavToday = useCallback(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
setBaseDate(d);
}, []);
// ── 이벤트 바 위치 계산 ──
const getBarStyle = useCallback(
(startDateStr: string, endDateStr: string) => {
const evStart = parseDate(startDateStr);
const evEnd = parseDate(endDateStr);
const firstDate = dates[0];
const lastDate = dates[dates.length - 1];
// 완전히 범위 밖이면 표시하지 않음
if (evEnd < firstDate || evStart > lastDate) return null;
const startIdx = Math.max(0, diffDays(firstDate, evStart));
const endIdx = Math.min(config.spanDays - 1, diffDays(firstDate, evEnd));
const left = startIdx * config.cellWidth;
const width = (endIdx - startIdx + 1) * config.cellWidth;
return { left, width };
},
[dates, config.cellWidth, config.spanDays]
);
// ── 드래그/리사이즈 핸들러 ──
const handleMouseDown = useCallback(
(
e: React.MouseEvent,
eventId: string | number,
mode: "move" | "resize-left" | "resize-right",
startDate: string,
endDate: string
) => {
e.preventDefault();
e.stopPropagation();
setDragState({
eventId,
mode,
origStartDate: startDate,
origEndDate: endDate,
startX: e.clientX,
currentOffsetDays: 0,
});
},
[]
);
// mousemove / mouseup (document-level)
useEffect(() => {
if (!dragState) return;
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - dragState.startX;
const dayOffset = Math.round(dx / config.cellWidth);
setDragState((prev) => (prev ? { ...prev, currentOffsetDays: dayOffset } : null));
};
const handleMouseUp = (e: MouseEvent) => {
if (!dragState) return;
const dx = e.clientX - dragState.startX;
const dayOffset = Math.round(dx / config.cellWidth);
if (dayOffset !== 0) {
const origStart = parseDate(dragState.origStartDate);
const origEnd = parseDate(dragState.origEndDate);
if (dragState.mode === "move") {
const newStart = toDateStr(addDays(origStart, dayOffset));
const newEnd = toDateStr(addDays(origEnd, dayOffset));
onEventMove?.(dragState.eventId, newStart, newEnd);
} else if (dragState.mode === "resize-left") {
const newStart = toDateStr(addDays(origStart, dayOffset));
const newEnd = dragState.origEndDate.split("T")[0];
// 시작이 종료를 넘지 않도록
if (parseDate(newStart) <= parseDate(newEnd)) {
onEventResize?.(dragState.eventId, newStart, newEnd);
}
} else if (dragState.mode === "resize-right") {
const newStart = dragState.origStartDate.split("T")[0];
const newEnd = toDateStr(addDays(origEnd, dayOffset));
if (parseDate(newStart) <= parseDate(newEnd)) {
onEventResize?.(dragState.eventId, newStart, newEnd);
}
}
}
setDragState(null);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [dragState, config.cellWidth, onEventMove, onEventResize]);
// 드래그 중인 이벤트의 현재 표시 위치 계산
const getDraggedBarStyle = useCallback(
(event: TimelineEvent) => {
if (!dragState || dragState.eventId !== event.id) return null;
const origStart = parseDate(dragState.origStartDate);
const origEnd = parseDate(dragState.origEndDate);
const offset = dragState.currentOffsetDays;
let newStart: Date, newEnd: Date;
if (dragState.mode === "move") {
newStart = addDays(origStart, offset);
newEnd = addDays(origEnd, offset);
} else if (dragState.mode === "resize-left") {
newStart = addDays(origStart, offset);
newEnd = origEnd;
if (newStart > newEnd) newStart = newEnd;
} else {
newStart = origStart;
newEnd = addDays(origEnd, offset);
if (newEnd < newStart) newEnd = newStart;
}
return getBarStyle(toDateStr(newStart), toDateStr(newEnd));
},
[dragState, getBarStyle]
);
// ── 오늘 라인 위치 ──
const todayLineLeft = useMemo(() => {
if (!showTodayLine || dates.length === 0) return null;
const firstDate = dates[0];
const lastDate = dates[dates.length - 1];
if (today < firstDate || today > lastDate) return null;
const idx = diffDays(firstDate, today);
return idx * config.cellWidth + config.cellWidth / 2;
}, [dates, today, config.cellWidth, showTodayLine]);
// ── 상태 색상 매핑 ──
const getStatusColor = useCallback(
(status?: string) => {
if (!status) return statusColors[0]?.bgClass || "from-blue-500 to-blue-600";
const found = statusColors.find((c) => c.key === status);
return found?.bgClass || statusColors[0]?.bgClass || "from-blue-500 to-blue-600";
},
[statusColors]
);
// ── 날짜 헤더 그룹 ──
const dateGroups = useMemo(() => {
if (zoom === "day") {
return null; // day 뷰에서는 상위 그룹 없이 바로 날짜 표시
}
// week / month 뷰: 월 단위로 그룹
const groups: { label: string; span: number; startIdx: number }[] = [];
let currentMonth = -1;
let currentYear = -1;
for (let i = 0; i < dates.length; i++) {
const d = dates[i];
if (d.getMonth() !== currentMonth || d.getFullYear() !== currentYear) {
groups.push({
label: `${d.getFullYear()}${MONTH_NAMES[d.getMonth()]}`,
span: 1,
startIdx: i,
});
currentMonth = d.getMonth();
currentYear = d.getFullYear();
} else {
groups[groups.length - 1].span++;
}
}
return groups;
}, [dates, zoom]);
// ── 렌더링 ──
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (resources.length === 0 || events.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
{emptyIcon}
<p className="text-base font-medium mb-2 mt-3">{emptyMessage}</p>
</div>
);
}
const barHeight = 24;
const barGap = 2;
return (
<div className="flex flex-col gap-3">
{/* 컨트롤 바 */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={handleNavPrev}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={handleNavToday}>
<CalendarDays className="mr-1 h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={handleNavNext}>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground ml-2">
{toDateStr(dates[0])} ~ {toDateStr(dates[dates.length - 1])}
</span>
</div>
<div className="flex items-center gap-1">
{(["day", "week", "month"] as ZoomLevel[]).map((z) => (
<Button
key={z}
size="sm"
variant={zoom === z ? "default" : "outline"}
className="h-7 text-xs px-3"
onClick={() => handleZoom(z)}
>
{z === "day" ? "일" : z === "week" ? "주" : "월"}
</Button>
))}
</div>
</div>
{/* 범례 */}
{showLegend && (
<div className="flex items-center gap-4 flex-wrap text-xs">
<span className="font-semibold text-muted-foreground">:</span>
{statusColors.map((sc) => (
<div key={sc.key} className="flex items-center gap-1.5">
<div className={cn("h-3.5 w-5 rounded bg-gradient-to-br", sc.bgClass)} />
<span>{sc.label}</span>
</div>
))}
{showMilestones && (
<div className="flex items-center gap-1.5">
<Diamond className="h-3.5 w-3.5 text-purple-500 fill-purple-500" />
<span></span>
</div>
)}
{conflictDetection && (
<div className="flex items-center gap-1.5">
<div className="h-3.5 w-5 rounded border-2 border-red-500 bg-red-500/20" />
<span></span>
</div>
)}
</div>
)}
{/* 타임라인 본체 */}
<div className="rounded-lg border bg-background overflow-hidden">
<div
ref={scrollRef}
className="overflow-x-auto overflow-y-auto"
style={{ maxHeight: "calc(100vh - 350px)" }}
>
<div className="flex" style={{ minWidth: resourceWidth + totalWidth }}>
{/* 좌측: 리소스 라벨 */}
<div
className="shrink-0 border-r bg-muted/30 z-20 sticky left-0"
style={{ width: resourceWidth }}
>
{/* 헤더 공간 */}
<div
className="border-b bg-muted/50 flex items-center justify-center text-xs font-semibold text-muted-foreground"
style={{ height: dateGroups ? 60 : 36 }}
>
</div>
{/* 리소스 행 */}
{resources.map((res) => {
const laneCount = resourceLaneCounts.get(res.id) || 1;
const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12);
return (
<div
key={res.id}
className="border-b px-3 flex flex-col justify-center"
style={{ height: h }}
>
<span className="text-xs font-semibold text-foreground truncate">
{res.label}
</span>
{res.subLabel && (
<span className="text-[10px] text-muted-foreground truncate">
{res.subLabel}
</span>
)}
</div>
);
})}
</div>
{/* 우측: 타임라인 그리드 */}
<div className="flex-1 relative" ref={gridRef} style={{ width: totalWidth }}>
{/* 날짜 헤더 */}
<div className="sticky top-0 z-10 bg-background border-b">
{/* 상위 그룹 (월) */}
{dateGroups && (
<div className="flex border-b">
{dateGroups.map((g, idx) => (
<div
key={idx}
className="text-center text-[11px] font-semibold text-muted-foreground border-r py-1"
style={{ width: g.span * config.cellWidth }}
>
{g.label}
</div>
))}
</div>
)}
{/* 하위 날짜 셀 */}
<div className="flex">
{dates.map((date, idx) => {
const isT = isSameDay(date, today);
const isW = isWeekend(date);
return (
<div
key={idx}
className={cn(
"text-center border-r select-none",
isW && "text-red-400",
isT && "bg-primary/10 font-bold text-primary"
)}
style={{
width: config.cellWidth,
minWidth: config.cellWidth,
fontSize: zoom === "month" ? 9 : 11,
padding: zoom === "month" ? "2px 0" : "3px 0",
}}
>
{zoom === "month" ? (
<div>{date.getDate()}</div>
) : (
<>
<div className="font-semibold">{date.getDate()}</div>
<div>{DAY_NAMES[date.getDay()]}</div>
</>
)}
</div>
);
})}
</div>
</div>
{/* 리소스별 이벤트 행 */}
{resources.map((res) => {
const resEvents = eventsByResource.get(res.id) || [];
const laneCount = resourceLaneCounts.get(res.id) || 1;
const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12);
return (
<div key={res.id} className="relative border-b" style={{ height: h }}>
{/* 배경 그리드 */}
<div className="absolute inset-0 flex pointer-events-none">
{dates.map((date, idx) => (
<div
key={idx}
className={cn(
"border-r border-border/20",
isWeekend(date) && "bg-red-500/[0.03]"
)}
style={{ width: config.cellWidth, minWidth: config.cellWidth }}
/>
))}
</div>
{/* 오늘 라인 */}
{todayLineLeft != null && (
<div
className="absolute top-0 bottom-0 w-[2px] bg-red-500 z-[5] pointer-events-none"
style={{ left: todayLineLeft }}
/>
)}
{/* 이벤트 바 */}
{resEvents.map((ev) => {
if (ev.isMilestone && showMilestones) {
// 마일스톤: 다이아몬드 아이콘
const pos = getBarStyle(ev.startDate, ev.startDate);
if (!pos) return null;
return (
<div
key={ev.id}
className="absolute z-10 flex items-center justify-center cursor-pointer"
style={{
left: pos.left + pos.width / 2 - 8,
top: "50%",
transform: "translateY(-50%)",
}}
title={ev.label || "마일스톤"}
onClick={() => onEventClick?.(ev)}
>
<Diamond className="h-4 w-4 text-purple-500 fill-purple-500" />
</div>
);
}
// 일반 이벤트 바
const isDragging = dragState?.eventId === ev.id;
const barStyle = isDragging
? getDraggedBarStyle(ev)
: getBarStyle(ev.startDate, ev.endDate);
if (!barStyle) return null;
const lane = eventLanes.get(ev.id) || 0;
const colorClass = getStatusColor(ev.status);
const isConflict = conflictIds.has(ev.id);
const progress = ev.progress ?? 0;
return (
<div
key={ev.id}
className={cn(
"absolute rounded shadow-sm z-10 group select-none",
`bg-gradient-to-br ${colorClass}`,
isDragging && "opacity-80 shadow-lg z-20",
isConflict && "ring-2 ring-red-500 ring-offset-1",
"cursor-grab active:cursor-grabbing"
)}
style={{
left: barStyle.left,
width: Math.max(barStyle.width, config.cellWidth * 0.5),
height: barHeight,
top: 6 + lane * (barHeight + barGap),
}}
title={`${ev.label || ""} | ${ev.startDate.split("T")[0]} ~ ${ev.endDate.split("T")[0]}${progress > 0 ? ` | ${progress}%` : ""}`}
onClick={(e) => {
if (!isDragging) {
e.stopPropagation();
onEventClick?.(ev);
}
}}
onMouseDown={(e) => handleMouseDown(e, ev.id, "move", ev.startDate, ev.endDate)}
>
{/* 진행률 바 */}
{showProgress && progress > 0 && (
<div
className="absolute inset-y-0 left-0 rounded-l bg-white/25"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
)}
{/* 라벨 */}
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-bold text-white drop-shadow-sm truncate px-1">
{ev.label || ""}
{showProgress && progress > 0 && (
<span className="ml-1 opacity-75">({progress}%)</span>
)}
</span>
{/* 좌측 리사이즈 핸들 */}
<div
className="absolute left-0 top-0 bottom-0 w-[5px] cursor-col-resize opacity-0 group-hover:opacity-100 bg-white/30 rounded-l"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, ev.id, "resize-left", ev.startDate, ev.endDate);
}}
/>
{/* 우측 리사이즈 핸들 */}
<div
className="absolute right-0 top-0 bottom-0 w-[5px] cursor-col-resize opacity-0 group-hover:opacity-100 bg-white/30 rounded-r"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, ev.id, "resize-right", ev.startDate, ev.endDate);
}}
/>
</div>
);
})}
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}