"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 = { 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 { const conflictIds = new Set(); const byResource = new Map(); 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(propZoom || "week"); const [baseDate, setBaseDate] = useState(() => { 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(null); const scrollRef = useRef(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(); }, [events, conflictDetection]); // 리소스별 이벤트 그룹 const eventsByResource = useMemo(() => { const map = new Map(); 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(); 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(); 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 (
); } if (resources.length === 0 || events.length === 0) { return (
{emptyIcon}

{emptyMessage}

); } const barHeight = 24; const barGap = 2; return (
{/* 컨트롤 바 */}
{toDateStr(dates[0])} ~ {toDateStr(dates[dates.length - 1])}
{(["day", "week", "month"] as ZoomLevel[]).map((z) => ( ))}
{/* 범례 */} {showLegend && (
상태: {statusColors.map((sc) => (
{sc.label}
))} {showMilestones && (
마일스톤
)} {conflictDetection && (
충돌
)}
)} {/* 타임라인 본체 */}
{/* 좌측: 리소스 라벨 */}
{/* 헤더 공간 */}
리소스
{/* 리소스 행 */} {resources.map((res) => { const laneCount = resourceLaneCounts.get(res.id) || 1; const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12); return (
{res.label} {res.subLabel && ( {res.subLabel} )}
); })}
{/* 우측: 타임라인 그리드 */}
{/* 날짜 헤더 */}
{/* 상위 그룹 (월) */} {dateGroups && (
{dateGroups.map((g, idx) => (
{g.label}
))}
)} {/* 하위 날짜 셀 */}
{dates.map((date, idx) => { const isT = isSameDay(date, today); const isW = isWeekend(date); return (
{zoom === "month" ? (
{date.getDate()}
) : ( <>
{date.getDate()}
{DAY_NAMES[date.getDay()]}
)}
); })}
{/* 리소스별 이벤트 행 */} {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 (
{/* 배경 그리드 */}
{dates.map((date, idx) => (
))}
{/* 오늘 라인 */} {todayLineLeft != null && (
)} {/* 이벤트 바 */} {resEvents.map((ev) => { if (ev.isMilestone && showMilestones) { // 마일스톤: 다이아몬드 아이콘 const pos = getBarStyle(ev.startDate, ev.startDate); if (!pos) return null; return (
onEventClick?.(ev)} >
); } // 일반 이벤트 바 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 (
0 ? ` | ${progress}%` : ""}`} onClick={(e) => { if (!isDragging) { e.stopPropagation(); onEventClick?.(ev); } }} onMouseDown={(e) => handleMouseDown(e, ev.id, "move", ev.startDate, ev.endDate)} > {/* 진행률 바 */} {showProgress && progress > 0 && (
)} {/* 라벨 */} {ev.label || ""} {showProgress && progress > 0 && ( ({progress}%) )} {/* 좌측 리사이즈 핸들 */}
{ e.stopPropagation(); handleMouseDown(e, ev.id, "resize-left", ev.startDate, ev.endDate); }} /> {/* 우측 리사이즈 핸들 */}
{ e.stopPropagation(); handleMouseDown(e, ev.id, "resize-right", ev.startDate, ev.endDate); }} />
); })}
); })}
); }