- Updated the `getQualityReportData` function to utilize `inspection_result_mng` for quality report generation, enhancing data accuracy by aggregating inspection results. - Refined date handling in the `getOrderSummary` function to improve filtering logic and ensure accurate stock calculations. - Implemented virtual scrolling in the `TimelineScheduler` component to optimize performance when rendering large datasets. These changes enhance data retrieval efficiency and user experience across analytics and production planning modules.
973 lines
36 KiB
TypeScript
973 lines
36 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" },
|
|
];
|
|
|
|
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,
|
|
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;
|
|
});
|
|
|
|
// 드래그/리사이즈 상태
|
|
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 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]);
|
|
|
|
// 표시 범위 변경 시 부모에 알림 (데이터 재조회 트리거용)
|
|
// 부모가 인라인 함수로 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, config.spanDays - 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, 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]);
|
|
|
|
// ── 가상 스크롤 (리소스 행) ──
|
|
// 대량 리소스(수천~만 단위) 환경에서 보이는 행만 DOM 마운트하여 성능 확보
|
|
const barHeight = 24;
|
|
const barGap = 2;
|
|
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, -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,
|
|
startScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
|
currentOffsetDays: 0,
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
// 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, config.spanDays - 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, config.spanDays, 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]
|
|
);
|
|
|
|
// ── 날짜 헤더 그룹 ──
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
// 데이터 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>
|
|
<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>
|
|
)}
|
|
|
|
{/* 타임라인 본체 — 데이터 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: dateGroups ? 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">
|
|
{/* 상위 그룹 (월) */}
|
|
{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>
|
|
|
|
{/* 리소스별 이벤트 행 — 가상화 (좌측 컬럼과 동일 인덱스/높이) */}
|
|
<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">
|
|
{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) => {
|
|
// 드래그 직후 자동 발생하는 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>
|
|
|
|
{/* 좌측 리사이즈 핸들 */}
|
|
<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>
|
|
);
|
|
}
|