- Implemented a new endpoint for batch registration of process equipment, allowing users to add multiple equipment codes at once while skipping duplicates. - Enhanced error handling to provide detailed feedback on the registration process, including the number of successfully inserted and skipped items. - Updated the process info routes to include the new batch registration functionality. (TASK: ERP-node-087)
1056 lines
41 KiB
TypeScript
1056 lines
41 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* TimelineScheduler — 하드코딩 페이지용 공통 타임라인 스케줄러 컴포넌트
|
|
*
|
|
* 기능:
|
|
* - 리소스(설비/품목) 기준 Y축, 날짜 기준 X축
|
|
* - 줌 레벨 전환 (일/주/월)
|
|
* - 날짜 네비게이션 (이전/다음/오늘)
|
|
* - 이벤트 바 드래그 이동
|
|
* - 이벤트 바 리사이즈 (좌/우 핸들)
|
|
* - 오늘 날짜 빨간 세로선
|
|
* - 진행률 바 시각화
|
|
* - 마일스톤 (다이아몬드) 표시
|
|
* - 상태별 색상 + 범례
|
|
* - 충돌 감지 (같은 리소스에서 겹침 시 빨간 테두리)
|
|
*/
|
|
|
|
import React, { useState, useRef, useCallback, useMemo, useEffect } from "react";
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
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;
|
|
/** 표시 기간(이전/다음/오늘 또는 줌 변경) 변경 시 호출 — 부모가 데이터 재조회 등에 사용 */
|
|
onRangeChange?: (startDate: string, endDate: 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" },
|
|
];
|
|
|
|
// cellWidth = "1일당 픽셀(pxPerDay)". 막대/드래그/오늘선은 일 비율로 정확히 계산하고,
|
|
// 헤더 축/배경 그리드만 zoom에 따라 일·주차·월 버킷 컬럼으로 묶어 렌더한다.
|
|
// - 일: 1칸=1일, 주: 1칸=월내 N주차(최대 7일), 월: 1칸=한 달
|
|
const ZOOM_CONFIG: Record<ZoomLevel, { cellWidth: number; spanDays: number; navStep: number }> = {
|
|
day: { cellWidth: 60, spanDays: 28, navStep: 7 },
|
|
week: { cellWidth: 24, spanDays: 70, navStep: 28 }, // 주당 ≈168px, 약 10주 표시
|
|
month: { cellWidth: 7, spanDays: 150, navStep: 60 }, // 월당 ≈210px, 약 5개월 표시
|
|
};
|
|
|
|
// ─── 유틸리티 함수 ───
|
|
|
|
/** 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,
|
|
onRangeChange,
|
|
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;
|
|
});
|
|
// 사용자가 시작~종료를 직접 지정하면 zoom 고정 기간 대신 이 일수를 사용 (null=기본)
|
|
const [spanOverride, setSpanOverride] = useState<number | null>(null);
|
|
|
|
// 드래그/리사이즈 상태
|
|
const [dragState, setDragState] = useState<{
|
|
eventId: string | number;
|
|
mode: "move" | "resize-left" | "resize-right";
|
|
origStartDate: string;
|
|
origEndDate: string;
|
|
startX: number;
|
|
startScrollLeft: number;
|
|
currentOffsetDays: number;
|
|
} | null>(null);
|
|
|
|
const gridRef = useRef<HTMLDivElement>(null);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
// 드래그 이동(move) 직후 자동 발생하는 click 이벤트 1회를 무시하기 위한 플래그.
|
|
// 드래그로 일정이 변경된 직후에 모달이 자동 오픈되면서 이전 날짜가 표시되는 버그(TASK:ERP-006) 방지용.
|
|
const justDraggedRef = useRef(false);
|
|
|
|
// 줌 레벨 동기화
|
|
useEffect(() => {
|
|
if (propZoom && propZoom !== zoom) setZoom(propZoom);
|
|
}, [propZoom]);
|
|
|
|
const config = ZOOM_CONFIG[zoom];
|
|
const pxPerDay = config.cellWidth; // 1일당 픽셀 (막대/그리드/오늘선 공통 기준)
|
|
// 실제 표시 일수: 사용자가 시작~종료를 지정했으면 그 일수, 아니면 zoom 기본 기간
|
|
const effectiveSpanDays = spanOverride ?? config.spanDays;
|
|
// 이전/다음 이동 폭: 사용자 지정 기간이면 그 기간만큼, 아니면 zoom 기본 navStep
|
|
const navStep = spanOverride ?? config.navStep;
|
|
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 < effectiveSpanDays; i++) {
|
|
arr.push(addDays(baseDate, i));
|
|
}
|
|
return arr;
|
|
}, [baseDate, effectiveSpanDays]);
|
|
|
|
// 표시 범위 변경 시 부모에 알림 (데이터 재조회 트리거용)
|
|
// 부모가 인라인 함수로 onRangeChange를 넘기는 경우(매 렌더마다 새 참조) useEffect가
|
|
// 무한 트리거되어 < 오늘 > 클릭 시 로딩이 끝나지 않는 문제가 있었음.
|
|
// onRangeChange는 의존성에서 제외하고, 실제 시작/종료 날짜가 바뀐 경우에만 호출.
|
|
const lastRangeRef = useRef<{ s: string; e: string } | null>(null);
|
|
useEffect(() => {
|
|
if (!onRangeChange) return;
|
|
const start = toDateStr(baseDate);
|
|
const end = toDateStr(addDays(baseDate, effectiveSpanDays - 1));
|
|
if (lastRangeRef.current?.s === start && lastRangeRef.current?.e === end) return;
|
|
lastRangeRef.current = { s: start, e: end };
|
|
onRangeChange(start, end);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [baseDate, effectiveSpanDays]);
|
|
|
|
const totalWidth = pxPerDay * effectiveSpanDays;
|
|
|
|
// 충돌 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;
|
|
}
|
|
// 잘못된/누락 날짜 가드: NaN이면 lane 계산이 무너져 박스가 겹침.
|
|
// start 무효 → 0, end 무효 또는 start 미만 → start 로 보정 (end >= start 보장).
|
|
const sRaw = parseDate(ev.startDate).getTime();
|
|
const eRaw = parseDate(ev.endDate).getTime();
|
|
const evStart = Number.isFinite(sRaw) ? sRaw : 0;
|
|
const evEnd = Number.isFinite(eRaw) ? Math.max(eRaw, evStart) : evStart;
|
|
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]);
|
|
|
|
// ── 가상 스크롤 (리소스 행) ──
|
|
// 대량 리소스(수천~만 단위) 환경에서 보이는 행만 DOM 마운트하여 성능 확보
|
|
const barHeight = 24;
|
|
// 세로 스택 시 박스 간 간격 — 좁으면 라벨 숫자끼리 붙어 보임. 명확히 분리.
|
|
const barGap = 6;
|
|
const getRowHeight = useCallback((idx: number) => {
|
|
const res = resources[idx];
|
|
if (!res) return rowHeight;
|
|
const laneCount = resourceLaneCounts.get(res.id) || 1;
|
|
return Math.max(rowHeight, laneCount * (barHeight + barGap) + 12);
|
|
}, [resources, resourceLaneCounts, rowHeight]);
|
|
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: resources.length,
|
|
getScrollElement: () => scrollRef.current,
|
|
estimateSize: getRowHeight,
|
|
overscan: 8,
|
|
});
|
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
|
const totalRowsHeight = rowVirtualizer.getTotalSize();
|
|
|
|
// ── 줌/네비게이션 핸들러 ──
|
|
|
|
const handleZoom = useCallback(
|
|
(z: ZoomLevel) => {
|
|
setZoom(z);
|
|
onZoomChange?.(z);
|
|
},
|
|
[onZoomChange]
|
|
);
|
|
|
|
const handleNavPrev = useCallback(() => {
|
|
setBaseDate((prev) => addDays(prev, -navStep));
|
|
}, [navStep]);
|
|
|
|
const handleNavNext = useCallback(() => {
|
|
setBaseDate((prev) => addDays(prev, navStep));
|
|
}, [navStep]);
|
|
|
|
const handleNavToday = useCallback(() => {
|
|
const d = new Date();
|
|
d.setHours(0, 0, 0, 0);
|
|
setSpanOverride(null); // 사용자 지정 기간 해제 → zoom 기본 기간 복귀
|
|
setBaseDate(d);
|
|
}, []);
|
|
|
|
// 시작~종료 직접 지정
|
|
const handlePickStart = useCallback((v: string) => {
|
|
if (!v) return;
|
|
const s = parseDate(v);
|
|
if (isNaN(s.getTime())) return;
|
|
setBaseDate(s);
|
|
setSpanOverride((prev) => {
|
|
// 종료일 유지: 기존 종료(baseDate+span-1) 기준으로 일수 재계산
|
|
const curEnd = addDays(baseDate, (prev ?? config.spanDays) - 1);
|
|
const days = diffDays(s, curEnd) + 1;
|
|
return days >= 1 ? Math.min(days, 731) : 1;
|
|
});
|
|
}, [baseDate, config.spanDays]);
|
|
|
|
const handlePickEnd = useCallback((v: string) => {
|
|
if (!v) return;
|
|
const e = parseDate(v);
|
|
if (isNaN(e.getTime())) return;
|
|
const days = diffDays(baseDate, e) + 1;
|
|
setSpanOverride(days >= 1 ? Math.min(days, 731) : 1);
|
|
}, [baseDate]);
|
|
|
|
// ── 이벤트 바 위치 계산 ──
|
|
|
|
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(effectiveSpanDays - 1, diffDays(firstDate, evEnd));
|
|
const left = startIdx * config.cellWidth;
|
|
const width = (endIdx - startIdx + 1) * config.cellWidth;
|
|
|
|
return { left, width };
|
|
},
|
|
[dates, config.cellWidth, effectiveSpanDays]
|
|
);
|
|
|
|
// ── 드래그/리사이즈 핸들러 ──
|
|
|
|
const handleMouseDown = useCallback(
|
|
(
|
|
e: React.MouseEvent,
|
|
eventId: string | number,
|
|
mode: "move" | "resize-left" | "resize-right",
|
|
startDate: string,
|
|
endDate: string
|
|
) => {
|
|
// 주/월 뷰에서는 1칸이 여러 일이라 드래그로 일 단위 세밀 조정이 불가 → 일(day) 뷰에서만 허용
|
|
if (zoom !== "day") return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragState({
|
|
eventId,
|
|
mode,
|
|
origStartDate: startDate,
|
|
origEndDate: endDate,
|
|
startX: e.clientX,
|
|
startScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
|
currentOffsetDays: 0,
|
|
});
|
|
},
|
|
[zoom]
|
|
);
|
|
|
|
// mousemove / mouseup (document-level)
|
|
useEffect(() => {
|
|
if (!dragState) return;
|
|
|
|
// 드래그 결과가 차트 가시 범위를 벗어나지 않도록 오프셋 제한
|
|
const clampOffset = (rawOffset: number): number => {
|
|
const origStart = parseDate(dragState.origStartDate);
|
|
const origEnd = parseDate(dragState.origEndDate);
|
|
const lastDate = addDays(baseDate, effectiveSpanDays - 1);
|
|
const msPerDay = 86400000;
|
|
if (dragState.mode === "move") {
|
|
const minOffset = Math.ceil((baseDate.getTime() - origStart.getTime()) / msPerDay);
|
|
const maxOffset = Math.floor((lastDate.getTime() - origEnd.getTime()) / msPerDay);
|
|
return Math.max(minOffset, Math.min(maxOffset, rawOffset));
|
|
} else if (dragState.mode === "resize-left") {
|
|
const minOffset = Math.ceil((baseDate.getTime() - origStart.getTime()) / msPerDay);
|
|
const maxOffset = Math.floor((origEnd.getTime() - origStart.getTime()) / msPerDay);
|
|
return Math.max(minOffset, Math.min(maxOffset, rawOffset));
|
|
} else if (dragState.mode === "resize-right") {
|
|
const minOffset = Math.ceil((origStart.getTime() - origEnd.getTime()) / msPerDay);
|
|
const maxOffset = Math.floor((lastDate.getTime() - origEnd.getTime()) / msPerDay);
|
|
return Math.max(minOffset, Math.min(maxOffset, rawOffset));
|
|
}
|
|
return rawOffset;
|
|
};
|
|
|
|
// 스크롤 변화 보정: 드래그 시작 이후 스크롤된 만큼 dx에 더해줌
|
|
const getEffectiveDx = (clientX: number): number => {
|
|
const currentScrollLeft = scrollRef.current?.scrollLeft ?? 0;
|
|
const scrollDelta = currentScrollLeft - dragState.startScrollLeft;
|
|
return (clientX - dragState.startX) + scrollDelta;
|
|
};
|
|
|
|
// 자동 스크롤: 뷰포트 가장자리 근처에서 RAF 루프로 스크롤
|
|
const EDGE = 50; // 가장자리 감지 영역 (px)
|
|
const MAX_SPEED = 18; // 최대 스크롤 속도 (px per frame)
|
|
let rafId: number | null = null;
|
|
let lastClientX = 0;
|
|
|
|
const autoScrollTick = () => {
|
|
const sc = scrollRef.current;
|
|
if (!sc) { rafId = null; return; }
|
|
const rect = sc.getBoundingClientRect();
|
|
const leftDist = lastClientX - rect.left;
|
|
const rightDist = rect.right - lastClientX;
|
|
let delta = 0;
|
|
if (leftDist < EDGE) {
|
|
delta = -Math.round(((EDGE - Math.max(0, leftDist)) / EDGE) * MAX_SPEED);
|
|
} else if (rightDist < EDGE) {
|
|
delta = Math.round(((EDGE - Math.max(0, rightDist)) / EDGE) * MAX_SPEED);
|
|
}
|
|
if (delta !== 0) {
|
|
const before = sc.scrollLeft;
|
|
sc.scrollLeft = before + delta;
|
|
if (sc.scrollLeft !== before) {
|
|
// 스크롤이 실제로 변했으면 dragState.currentOffsetDays 재계산
|
|
const dx = getEffectiveDx(lastClientX);
|
|
const dayOffset = clampOffset(Math.round(dx / config.cellWidth));
|
|
setDragState((prev) => (prev ? { ...prev, currentOffsetDays: dayOffset } : null));
|
|
}
|
|
}
|
|
rafId = requestAnimationFrame(autoScrollTick);
|
|
};
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
lastClientX = e.clientX;
|
|
const dx = getEffectiveDx(e.clientX);
|
|
const dayOffset = clampOffset(Math.round(dx / config.cellWidth));
|
|
setDragState((prev) => (prev ? { ...prev, currentOffsetDays: dayOffset } : null));
|
|
if (rafId === null) rafId = requestAnimationFrame(autoScrollTick);
|
|
};
|
|
|
|
const handleMouseUp = (e: MouseEvent) => {
|
|
if (!dragState) return;
|
|
const dx = getEffectiveDx(e.clientX);
|
|
const dayOffset = clampOffset(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);
|
|
// 드래그 직후 브라우저가 자동 디스패치하는 click 이벤트 1회를 무시해
|
|
// 모달이 이전 날짜로 자동 오픈되는 버그(TASK:ERP-006) 방지.
|
|
justDraggedRef.current = true;
|
|
setTimeout(() => {
|
|
justDraggedRef.current = false;
|
|
}, 0);
|
|
} 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);
|
|
}
|
|
justDraggedRef.current = true;
|
|
setTimeout(() => {
|
|
justDraggedRef.current = false;
|
|
}, 0);
|
|
} 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);
|
|
}
|
|
justDraggedRef.current = true;
|
|
setTimeout(() => {
|
|
justDraggedRef.current = false;
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
setDragState(null);
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
};
|
|
}, [dragState, config.cellWidth, effectiveSpanDays, baseDate, 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]
|
|
);
|
|
|
|
// ── 날짜 헤더 그룹 ──
|
|
|
|
// 상단 tier: 월(연도) 그룹 — 일/주 뷰 공통. 월 뷰는 컬럼 자체가 달이라 null.
|
|
const monthGroups = useMemo(() => {
|
|
if (zoom === "month") return null;
|
|
const groups: { label: string; days: number; startIdx: number }[] = [];
|
|
let cm = -1, cy = -1;
|
|
for (let i = 0; i < dates.length; i++) {
|
|
const d = dates[i];
|
|
if (d.getMonth() !== cm || d.getFullYear() !== cy) {
|
|
groups.push({ label: `${d.getFullYear()}년 ${MONTH_NAMES[d.getMonth()]}`, days: 1, startIdx: i });
|
|
cm = d.getMonth(); cy = d.getFullYear();
|
|
} else {
|
|
groups[groups.length - 1].days++;
|
|
}
|
|
}
|
|
return groups;
|
|
}, [dates, zoom]);
|
|
|
|
// 하단 tier: 축 단위 컬럼. 일=하루 / 주=월내 N주차(ceil(일/7)) / 월=한 달.
|
|
const columns = useMemo(() => {
|
|
type Col = { startIdx: number; days: number; label: string; sub?: string; isToday: boolean; isWeekend: boolean };
|
|
const cols: Col[] = [];
|
|
if (zoom === "day") {
|
|
dates.forEach((d, i) => cols.push({
|
|
startIdx: i, days: 1,
|
|
label: String(d.getDate()), sub: DAY_NAMES[d.getDay()],
|
|
isToday: isSameDay(d, today), isWeekend: isWeekend(d),
|
|
}));
|
|
} else {
|
|
let key = "";
|
|
dates.forEach((d, i) => {
|
|
let k: string, label: string;
|
|
if (zoom === "week") {
|
|
const wom = Math.ceil(d.getDate() / 7); // 1~7→1, 8~14→2 ... (월내 주차)
|
|
k = `${d.getFullYear()}-${d.getMonth()}-${wom}`;
|
|
label = `${MONTH_NAMES[d.getMonth()]} ${wom}주차`;
|
|
} else {
|
|
k = `${d.getFullYear()}-${d.getMonth()}`;
|
|
label = `${d.getFullYear()}년 ${MONTH_NAMES[d.getMonth()]}`;
|
|
}
|
|
if (k !== key) {
|
|
cols.push({ startIdx: i, days: 1, label, isToday: false, isWeekend: false });
|
|
key = k;
|
|
} else {
|
|
cols[cols.length - 1].days++;
|
|
}
|
|
if (isSameDay(d, today)) cols[cols.length - 1].isToday = true;
|
|
});
|
|
}
|
|
return cols;
|
|
}, [dates, zoom, today]);
|
|
|
|
// ── 렌더링 ──
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-16">
|
|
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 데이터 0건이어도 네비게이션 컨트롤(이전/오늘/다음/줌)은 노출하여 이전 기간 탐색 가능하도록.
|
|
const hasData = resources.length > 0 && events.length > 0;
|
|
|
|
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>
|
|
{/* 시작~종료 직접 선택 (자유 기간 보기) */}
|
|
<div className="flex items-center gap-1 ml-2">
|
|
<input
|
|
type="date"
|
|
value={toDateStr(dates[0])}
|
|
onChange={(e) => handlePickStart(e.target.value)}
|
|
className="h-7 rounded border border-input bg-background px-2 text-xs text-foreground"
|
|
title="시작일"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">~</span>
|
|
<input
|
|
type="date"
|
|
value={toDateStr(dates[dates.length - 1])}
|
|
min={toDateStr(dates[0])}
|
|
onChange={(e) => handlePickEnd(e.target.value)}
|
|
className="h-7 rounded border border-input bg-background px-2 text-xs text-foreground"
|
|
title="종료일"
|
|
/>
|
|
{spanOverride != null && (
|
|
<span className="text-[10px] text-muted-foreground ml-1">({effectiveSpanDays}일)</span>
|
|
)}
|
|
</div>
|
|
</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>
|
|
)}
|
|
|
|
{/* 타임라인 본체 — 데이터 0건이면 빈 안내, 컨트롤 바는 위에서 항상 노출 */}
|
|
{!hasData ? (
|
|
<div className="rounded-lg border bg-background 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>
|
|
<p className="text-xs">현재 표시 범위: {toDateStr(dates[0])} ~ {toDateStr(dates[dates.length - 1])}</p>
|
|
</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-background z-20 sticky left-0"
|
|
style={{ width: resourceWidth }}
|
|
>
|
|
{/* 좌상단 코너 — 가로/세로 스크롤 모두에서 고정 (이벤트 바보다 위) */}
|
|
<div
|
|
className="sticky top-0 z-40 border-b bg-muted/50 flex items-center justify-center text-xs font-semibold text-muted-foreground"
|
|
style={{ height: monthGroups ? 60 : 36 }}
|
|
>
|
|
리소스
|
|
</div>
|
|
{/* 리소스 행 — 가상화 (보이는 행만 마운트) */}
|
|
<div style={{ height: totalRowsHeight, position: "relative" }}>
|
|
{virtualItems.map((vRow) => {
|
|
const res = resources[vRow.index];
|
|
if (!res) return null;
|
|
return (
|
|
<div
|
|
key={res.id}
|
|
className="border-b px-3 flex flex-col justify-center"
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: vRow.size,
|
|
transform: `translateY(${vRow.start}px)`,
|
|
}}
|
|
>
|
|
<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>
|
|
|
|
{/* 우측: 타임라인 그리드 */}
|
|
<div className="flex-1 relative" ref={gridRef} style={{ width: totalWidth }}>
|
|
{/* 날짜 헤더 — 이벤트 바(z-10)보다 위로 올려야 스크롤 시 겹침 방지 */}
|
|
<div className="sticky top-0 z-30 bg-background border-b">
|
|
{/* 상위 그룹 (연·월) — 일/주 뷰 */}
|
|
{monthGroups && (
|
|
<div className="flex border-b">
|
|
{monthGroups.map((g, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="text-center text-[11px] font-semibold text-muted-foreground border-r py-1 whitespace-nowrap overflow-hidden"
|
|
style={{ width: g.days * pxPerDay }}
|
|
>
|
|
{g.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 하위 축 컬럼 — 일=날짜 / 주=N주차 / 월=연·월 */}
|
|
<div className="flex">
|
|
{columns.map((c, idx) => (
|
|
<div
|
|
key={idx}
|
|
className={cn(
|
|
"text-center border-r select-none overflow-hidden",
|
|
c.isWeekend && "text-red-400",
|
|
c.isToday && "bg-primary/10 font-bold text-primary"
|
|
)}
|
|
style={{
|
|
width: c.days * pxPerDay,
|
|
minWidth: c.days * pxPerDay,
|
|
fontSize: 11,
|
|
padding: "3px 0",
|
|
}}
|
|
>
|
|
{c.sub ? (
|
|
<>
|
|
<div className="font-semibold">{c.label}</div>
|
|
<div>{c.sub}</div>
|
|
</>
|
|
) : (
|
|
<div className="font-semibold whitespace-nowrap">{c.label}</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 리소스별 이벤트 행 — 가상화 (좌측 컬럼과 동일 인덱스/높이) */}
|
|
<div style={{ height: totalRowsHeight, position: "relative" }}>
|
|
{virtualItems.map((vRow) => {
|
|
const res = resources[vRow.index];
|
|
if (!res) return null;
|
|
const resEvents = eventsByResource.get(res.id) || [];
|
|
|
|
return (
|
|
<div
|
|
key={res.id}
|
|
className="border-b"
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: vRow.size,
|
|
transform: `translateY(${vRow.start}px)`,
|
|
}}
|
|
>
|
|
{/* 배경 그리드 */}
|
|
<div className="absolute inset-0 flex pointer-events-none">
|
|
{columns.map((c, idx) => (
|
|
<div
|
|
key={idx}
|
|
className={cn(
|
|
"border-r border-border/20",
|
|
c.isWeekend && "bg-red-500/[0.03]"
|
|
)}
|
|
style={{ width: c.days * pxPerDay, minWidth: c.days * pxPerDay }}
|
|
/>
|
|
))}
|
|
</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;
|
|
// 주/월 뷰에서는 드래그 일정 조정 비활성(세밀 조정 불가) — 일 뷰에서만 가능
|
|
const canDrag = zoom === "day";
|
|
|
|
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",
|
|
canDrag ? "cursor-grab active:cursor-grabbing" : "cursor-pointer"
|
|
)}
|
|
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) => {
|
|
// 드래그 직후 자동 발생하는 click은 무시 (TASK:ERP-006).
|
|
if (justDraggedRef.current) {
|
|
e.stopPropagation();
|
|
return;
|
|
}
|
|
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>
|
|
|
|
{/* 리사이즈 핸들 — 일 뷰에서만(주/월은 세밀 조정 불가) */}
|
|
{canDrag && (
|
|
<>
|
|
<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>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|