jskim-node #418
|
|
@ -531,7 +531,7 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul
|
||||||
- [x] 레지스트리 등록
|
- [x] 레지스트리 등록
|
||||||
- [x] 문서화 (README.md)
|
- [x] 문서화 (README.md)
|
||||||
|
|
||||||
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30)
|
#### v2-timeline-scheduler ✅ 구현 완료 (2026-01-30, 업데이트: 2026-03-13)
|
||||||
|
|
||||||
- [x] 타입 정의 완료
|
- [x] 타입 정의 완료
|
||||||
- [x] 기본 구조 생성
|
- [x] 기본 구조 생성
|
||||||
|
|
@ -539,12 +539,16 @@ function detectConflicts(schedules: ScheduleItem[], resourceId: string): Schedul
|
||||||
- [x] TimelineGrid (배경)
|
- [x] TimelineGrid (배경)
|
||||||
- [x] ResourceColumn (리소스)
|
- [x] ResourceColumn (리소스)
|
||||||
- [x] ScheduleBar 기본 렌더링
|
- [x] ScheduleBar 기본 렌더링
|
||||||
- [x] 드래그 이동 (기본)
|
- [x] 드래그 이동 (실제 로직: deltaX → 날짜 계산 → API 저장 → toast)
|
||||||
- [x] 리사이즈 (기본)
|
- [x] 리사이즈 (실제 로직: 시작/종료 핸들 → 기간 변경 → API 저장 → toast)
|
||||||
- [x] 줌 레벨 전환
|
- [x] 줌 레벨 전환
|
||||||
- [x] 날짜 네비게이션
|
- [x] 날짜 네비게이션
|
||||||
- [ ] 충돌 감지 (향후)
|
- [x] 충돌 감지 (같은 리소스 겹침 → ring-destructive + AlertTriangle)
|
||||||
- [ ] 가상 스크롤 (향후)
|
- [x] 마일스톤 표시 (시작일 = 종료일 → 다이아몬드 마커)
|
||||||
|
- [x] 범례 표시 (TimelineLegend: 상태별 색상 + 마일스톤 + 충돌)
|
||||||
|
- [x] 반응형 공통 CSS 적용 (text-[10px] sm:text-sm 패턴)
|
||||||
|
- [x] staticFilters 지원 (커스텀 테이블 필터링)
|
||||||
|
- [ ] 가상 스크롤 (향후 - 대용량 100+ 리소스)
|
||||||
- [x] 설정 패널 구현
|
- [x] 설정 패널 구현
|
||||||
- [x] API 연동
|
- [x] API 연동
|
||||||
- [x] 레지스트리 등록
|
- [x] 레지스트리 등록
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useMemo, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
|
@ -11,17 +11,16 @@ import {
|
||||||
ZoomOut,
|
ZoomOut,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
TimelineSchedulerComponentProps,
|
TimelineSchedulerComponentProps,
|
||||||
ScheduleItem,
|
ScheduleItem,
|
||||||
ZoomLevel,
|
ZoomLevel,
|
||||||
DragEvent,
|
|
||||||
ResizeEvent,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { useTimelineData } from "./hooks/useTimelineData";
|
import { useTimelineData } from "./hooks/useTimelineData";
|
||||||
import { TimelineHeader, ResourceRow } from "./components";
|
import { TimelineHeader, ResourceRow, TimelineLegend } from "./components";
|
||||||
import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config";
|
import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config";
|
||||||
|
import { detectConflicts, addDaysToDateString } from "./utils/conflictDetection";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v2-timeline-scheduler 메인 컴포넌트
|
* v2-timeline-scheduler 메인 컴포넌트
|
||||||
|
|
@ -45,19 +44,6 @@ export function TimelineSchedulerComponent({
|
||||||
}: TimelineSchedulerComponentProps) {
|
}: TimelineSchedulerComponentProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 드래그/리사이즈 상태
|
|
||||||
const [dragState, setDragState] = useState<{
|
|
||||||
schedule: ScheduleItem;
|
|
||||||
startX: number;
|
|
||||||
startY: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const [resizeState, setResizeState] = useState<{
|
|
||||||
schedule: ScheduleItem;
|
|
||||||
direction: "start" | "end";
|
|
||||||
startX: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// 타임라인 데이터 훅
|
// 타임라인 데이터 훅
|
||||||
const {
|
const {
|
||||||
schedules,
|
schedules,
|
||||||
|
|
@ -78,53 +64,43 @@ export function TimelineSchedulerComponent({
|
||||||
const error = externalError ?? hookError;
|
const error = externalError ?? hookError;
|
||||||
|
|
||||||
// 설정값
|
// 설정값
|
||||||
const rowHeight = config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!;
|
const rowHeight =
|
||||||
const headerHeight = config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!;
|
config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!;
|
||||||
|
const headerHeight =
|
||||||
|
config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!;
|
||||||
const resourceColumnWidth =
|
const resourceColumnWidth =
|
||||||
config.resourceColumnWidth || defaultTimelineSchedulerConfig.resourceColumnWidth!;
|
config.resourceColumnWidth ||
|
||||||
const cellWidthConfig = config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!;
|
defaultTimelineSchedulerConfig.resourceColumnWidth!;
|
||||||
|
const cellWidthConfig =
|
||||||
|
config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!;
|
||||||
const cellWidth = cellWidthConfig[zoomLevel] || 60;
|
const cellWidth = cellWidthConfig[zoomLevel] || 60;
|
||||||
|
|
||||||
// 리소스가 없으면 스케줄의 resourceId로 자동 생성
|
// 리소스 자동 생성 (리소스 테이블 미설정 시 스케줄 데이터에서 추출)
|
||||||
const effectiveResources = useMemo(() => {
|
const effectiveResources = useMemo(() => {
|
||||||
if (resources.length > 0) {
|
if (resources.length > 0) return resources;
|
||||||
return resources;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 스케줄에서 고유한 resourceId 추출하여 자동 리소스 생성
|
|
||||||
const uniqueResourceIds = new Set<string>();
|
const uniqueResourceIds = new Set<string>();
|
||||||
schedules.forEach((schedule) => {
|
schedules.forEach((s) => {
|
||||||
if (schedule.resourceId) {
|
if (s.resourceId) uniqueResourceIds.add(s.resourceId);
|
||||||
uniqueResourceIds.add(schedule.resourceId);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(uniqueResourceIds).map((id) => ({
|
return Array.from(uniqueResourceIds).map((id) => ({ id, name: id }));
|
||||||
id,
|
|
||||||
name: id, // resourceId를 이름으로 사용
|
|
||||||
}));
|
|
||||||
}, [resources, schedules]);
|
}, [resources, schedules]);
|
||||||
|
|
||||||
// 리소스별 스케줄 그룹화
|
// 리소스별 스케줄 그룹화
|
||||||
const schedulesByResource = useMemo(() => {
|
const schedulesByResource = useMemo(() => {
|
||||||
const grouped = new Map<string, ScheduleItem[]>();
|
const grouped = new Map<string, ScheduleItem[]>();
|
||||||
|
|
||||||
effectiveResources.forEach((resource) => {
|
effectiveResources.forEach((r) => grouped.set(r.id, []));
|
||||||
grouped.set(resource.id, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
schedules.forEach((schedule) => {
|
schedules.forEach((schedule) => {
|
||||||
const list = grouped.get(schedule.resourceId);
|
const list = grouped.get(schedule.resourceId);
|
||||||
if (list) {
|
if (list) {
|
||||||
list.push(schedule);
|
list.push(schedule);
|
||||||
} else {
|
} else {
|
||||||
// 리소스가 없는 스케줄은 첫 번째 리소스에 할당
|
|
||||||
const firstResource = effectiveResources[0];
|
const firstResource = effectiveResources[0];
|
||||||
if (firstResource) {
|
if (firstResource) {
|
||||||
const firstList = grouped.get(firstResource.id);
|
grouped.get(firstResource.id)?.push(schedule);
|
||||||
if (firstList) {
|
|
||||||
firstList.push(schedule);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -132,27 +108,31 @@ export function TimelineSchedulerComponent({
|
||||||
return grouped;
|
return grouped;
|
||||||
}, [schedules, effectiveResources]);
|
}, [schedules, effectiveResources]);
|
||||||
|
|
||||||
// 줌 레벨 변경
|
// ────────── 충돌 감지 ──────────
|
||||||
|
const conflictIds = useMemo(() => {
|
||||||
|
if (config.showConflicts === false) return new Set<string>();
|
||||||
|
return detectConflicts(schedules);
|
||||||
|
}, [schedules, config.showConflicts]);
|
||||||
|
|
||||||
|
// ────────── 줌 레벨 변경 ──────────
|
||||||
const handleZoomIn = useCallback(() => {
|
const handleZoomIn = useCallback(() => {
|
||||||
const levels: ZoomLevel[] = ["month", "week", "day"];
|
const levels: ZoomLevel[] = ["month", "week", "day"];
|
||||||
const currentIdx = levels.indexOf(zoomLevel);
|
const idx = levels.indexOf(zoomLevel);
|
||||||
if (currentIdx < levels.length - 1) {
|
if (idx < levels.length - 1) setZoomLevel(levels[idx + 1]);
|
||||||
setZoomLevel(levels[currentIdx + 1]);
|
|
||||||
}
|
|
||||||
}, [zoomLevel, setZoomLevel]);
|
}, [zoomLevel, setZoomLevel]);
|
||||||
|
|
||||||
const handleZoomOut = useCallback(() => {
|
const handleZoomOut = useCallback(() => {
|
||||||
const levels: ZoomLevel[] = ["month", "week", "day"];
|
const levels: ZoomLevel[] = ["month", "week", "day"];
|
||||||
const currentIdx = levels.indexOf(zoomLevel);
|
const idx = levels.indexOf(zoomLevel);
|
||||||
if (currentIdx > 0) {
|
if (idx > 0) setZoomLevel(levels[idx - 1]);
|
||||||
setZoomLevel(levels[currentIdx - 1]);
|
|
||||||
}
|
|
||||||
}, [zoomLevel, setZoomLevel]);
|
}, [zoomLevel, setZoomLevel]);
|
||||||
|
|
||||||
// 스케줄 클릭 핸들러
|
// ────────── 스케줄 클릭 ──────────
|
||||||
const handleScheduleClick = useCallback(
|
const handleScheduleClick = useCallback(
|
||||||
(schedule: ScheduleItem) => {
|
(schedule: ScheduleItem) => {
|
||||||
const resource = effectiveResources.find((r) => r.id === schedule.resourceId);
|
const resource = effectiveResources.find(
|
||||||
|
(r) => r.id === schedule.resourceId
|
||||||
|
);
|
||||||
if (resource && onScheduleClick) {
|
if (resource && onScheduleClick) {
|
||||||
onScheduleClick({ schedule, resource });
|
onScheduleClick({ schedule, resource });
|
||||||
}
|
}
|
||||||
|
|
@ -160,7 +140,7 @@ export function TimelineSchedulerComponent({
|
||||||
[effectiveResources, onScheduleClick]
|
[effectiveResources, onScheduleClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 빈 셀 클릭 핸들러
|
// ────────── 빈 셀 클릭 ──────────
|
||||||
const handleCellClick = useCallback(
|
const handleCellClick = useCallback(
|
||||||
(resourceId: string, date: Date) => {
|
(resourceId: string, date: Date) => {
|
||||||
if (onCellClick) {
|
if (onCellClick) {
|
||||||
|
|
@ -173,47 +153,111 @@ export function TimelineSchedulerComponent({
|
||||||
[onCellClick]
|
[onCellClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 드래그 시작
|
// ────────── 드래그 완료 (핵심 로직) ──────────
|
||||||
const handleDragStart = useCallback(
|
const handleDragComplete = useCallback(
|
||||||
(schedule: ScheduleItem, e: React.MouseEvent) => {
|
async (schedule: ScheduleItem, deltaX: number) => {
|
||||||
setDragState({
|
// 줌 레벨에 따라 1셀당 일수가 달라짐
|
||||||
schedule,
|
let daysPerCell = 1;
|
||||||
startX: e.clientX,
|
if (zoomLevel === "week") daysPerCell = 7;
|
||||||
startY: e.clientY,
|
if (zoomLevel === "month") daysPerCell = 30;
|
||||||
});
|
|
||||||
|
const deltaDays = Math.round((deltaX / cellWidth) * daysPerCell);
|
||||||
|
if (deltaDays === 0) return;
|
||||||
|
|
||||||
|
const newStartDate = addDaysToDateString(schedule.startDate, deltaDays);
|
||||||
|
const newEndDate = addDaysToDateString(schedule.endDate, deltaDays);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSchedule(schedule.id, {
|
||||||
|
startDate: newStartDate,
|
||||||
|
endDate: newEndDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 외부 이벤트 핸들러 호출
|
||||||
|
onDragEnd?.({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
newStartDate,
|
||||||
|
newEndDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("스케줄 이동 완료", {
|
||||||
|
description: `${schedule.title}: ${newStartDate} ~ ${newEndDate}`,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error("스케줄 이동 실패", {
|
||||||
|
description: err.message || "잠시 후 다시 시도해주세요",
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[]
|
[cellWidth, zoomLevel, updateSchedule, onDragEnd]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 드래그 종료
|
// ────────── 리사이즈 완료 (핵심 로직) ──────────
|
||||||
const handleDragEnd = useCallback(() => {
|
const handleResizeComplete = useCallback(
|
||||||
if (dragState) {
|
async (
|
||||||
// TODO: 드래그 결과 계산 및 업데이트
|
schedule: ScheduleItem,
|
||||||
setDragState(null);
|
direction: "start" | "end",
|
||||||
}
|
deltaX: number
|
||||||
}, [dragState]);
|
) => {
|
||||||
|
let daysPerCell = 1;
|
||||||
|
if (zoomLevel === "week") daysPerCell = 7;
|
||||||
|
if (zoomLevel === "month") daysPerCell = 30;
|
||||||
|
|
||||||
// 리사이즈 시작
|
const deltaDays = Math.round((deltaX / cellWidth) * daysPerCell);
|
||||||
const handleResizeStart = useCallback(
|
if (deltaDays === 0) return;
|
||||||
(schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => {
|
|
||||||
setResizeState({
|
let newStartDate = schedule.startDate;
|
||||||
schedule,
|
let newEndDate = schedule.endDate;
|
||||||
direction,
|
|
||||||
startX: e.clientX,
|
if (direction === "start") {
|
||||||
});
|
newStartDate = addDaysToDateString(schedule.startDate, deltaDays);
|
||||||
|
// 시작일이 종료일을 넘지 않도록
|
||||||
|
if (new Date(newStartDate) >= new Date(newEndDate)) {
|
||||||
|
toast.warning("시작일은 종료일보다 이전이어야 합니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newEndDate = addDaysToDateString(schedule.endDate, deltaDays);
|
||||||
|
// 종료일이 시작일보다 앞서지 않도록
|
||||||
|
if (new Date(newEndDate) <= new Date(newStartDate)) {
|
||||||
|
toast.warning("종료일은 시작일보다 이후여야 합니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateSchedule(schedule.id, {
|
||||||
|
startDate: newStartDate,
|
||||||
|
endDate: newEndDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
onResizeEnd?.({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
newStartDate,
|
||||||
|
newEndDate,
|
||||||
|
direction,
|
||||||
|
});
|
||||||
|
|
||||||
|
const days =
|
||||||
|
Math.round(
|
||||||
|
(new Date(newEndDate).getTime() -
|
||||||
|
new Date(newStartDate).getTime()) /
|
||||||
|
(1000 * 60 * 60 * 24)
|
||||||
|
) + 1;
|
||||||
|
|
||||||
|
toast.success("기간 변경 완료", {
|
||||||
|
description: `${schedule.title}: ${days}일 (${newStartDate} ~ ${newEndDate})`,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error("기간 변경 실패", {
|
||||||
|
description: err.message || "잠시 후 다시 시도해주세요",
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[]
|
[cellWidth, zoomLevel, updateSchedule, onResizeEnd]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 리사이즈 종료
|
// ────────── 추가 버튼 클릭 ──────────
|
||||||
const handleResizeEnd = useCallback(() => {
|
|
||||||
if (resizeState) {
|
|
||||||
// TODO: 리사이즈 결과 계산 및 업데이트
|
|
||||||
setResizeState(null);
|
|
||||||
}
|
|
||||||
}, [resizeState]);
|
|
||||||
|
|
||||||
// 추가 버튼 클릭
|
|
||||||
const handleAddClick = useCallback(() => {
|
const handleAddClick = useCallback(() => {
|
||||||
if (onAddSchedule && effectiveResources.length > 0) {
|
if (onAddSchedule && effectiveResources.length > 0) {
|
||||||
onAddSchedule(
|
onAddSchedule(
|
||||||
|
|
@ -223,7 +267,13 @@ export function TimelineSchedulerComponent({
|
||||||
}
|
}
|
||||||
}, [onAddSchedule, effectiveResources]);
|
}, [onAddSchedule, effectiveResources]);
|
||||||
|
|
||||||
// 디자인 모드 플레이스홀더
|
// ────────── 하단 영역 높이 계산 (툴바 + 범례) ──────────
|
||||||
|
const showToolbar = config.showToolbar !== false;
|
||||||
|
const showLegend = config.showLegend !== false;
|
||||||
|
const toolbarHeight = showToolbar ? 36 : 0;
|
||||||
|
const legendHeight = showLegend ? 28 : 0;
|
||||||
|
|
||||||
|
// ────────── 디자인 모드 플레이스홀더 ──────────
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-[200px] w-full items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/30 bg-muted/10">
|
<div className="flex h-full min-h-[200px] w-full items-center justify-center rounded-lg border-2 border-dashed border-muted-foreground/30 bg-muted/10">
|
||||||
|
|
@ -240,7 +290,7 @@ export function TimelineSchedulerComponent({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로딩 상태
|
// ────────── 로딩 상태 ──────────
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -255,7 +305,7 @@ export function TimelineSchedulerComponent({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 에러 상태
|
// ────────── 에러 상태 ──────────
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -270,7 +320,7 @@ export function TimelineSchedulerComponent({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 스케줄 데이터 없음
|
// ────────── 데이터 없음 ──────────
|
||||||
if (schedules.length === 0) {
|
if (schedules.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -279,9 +329,12 @@ export function TimelineSchedulerComponent({
|
||||||
>
|
>
|
||||||
<div className="text-center text-muted-foreground">
|
<div className="text-center text-muted-foreground">
|
||||||
<Calendar className="mx-auto mb-2 h-8 w-8 opacity-50 sm:mb-3 sm:h-10 sm:w-10" />
|
<Calendar className="mx-auto mb-2 h-8 w-8 opacity-50 sm:mb-3 sm:h-10 sm:w-10" />
|
||||||
<p className="text-xs font-medium sm:text-sm">스케줄 데이터가 없습니다</p>
|
<p className="text-xs font-medium sm:text-sm">
|
||||||
|
스케줄 데이터가 없습니다
|
||||||
|
</p>
|
||||||
<p className="mt-1.5 max-w-[200px] text-[10px] sm:mt-2 sm:text-xs">
|
<p className="mt-1.5 max-w-[200px] text-[10px] sm:mt-2 sm:text-xs">
|
||||||
좌측 테이블에서 품목을 선택하거나,<br />
|
좌측 테이블에서 품목을 선택하거나,
|
||||||
|
<br />
|
||||||
스케줄 생성 버튼을 눌러 스케줄을 생성하세요
|
스케줄 생성 버튼을 눌러 스케줄을 생성하세요
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -289,18 +342,19 @@ export function TimelineSchedulerComponent({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────── 메인 렌더링 ──────────
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="w-full overflow-hidden rounded-lg border bg-background"
|
className="flex w-full flex-col overflow-hidden rounded-lg border bg-background"
|
||||||
style={{
|
style={{
|
||||||
height: config.height || 500,
|
height: config.height || 500,
|
||||||
maxHeight: config.maxHeight,
|
maxHeight: config.maxHeight,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 툴바 */}
|
{/* 툴바 */}
|
||||||
{config.showToolbar !== false && (
|
{showToolbar && (
|
||||||
<div className="flex items-center justify-between border-b bg-muted/30 px-2 py-1.5 sm:px-3 sm:py-2">
|
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-2 py-1.5 sm:px-3 sm:py-2">
|
||||||
{/* 네비게이션 */}
|
{/* 네비게이션 */}
|
||||||
<div className="flex items-center gap-0.5 sm:gap-1">
|
<div className="flex items-center gap-0.5 sm:gap-1">
|
||||||
{config.showNavigation !== false && (
|
{config.showNavigation !== false && (
|
||||||
|
|
@ -332,16 +386,23 @@ export function TimelineSchedulerComponent({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 현재 날짜 범위 표시 */}
|
{/* 날짜 범위 표시 */}
|
||||||
<span className="ml-1 text-[10px] text-muted-foreground sm:ml-2 sm:text-sm">
|
<span className="ml-1 text-[10px] text-muted-foreground sm:ml-2 sm:text-sm">
|
||||||
{viewStartDate.getFullYear()}년 {viewStartDate.getMonth() + 1}월{" "}
|
{viewStartDate.getFullYear()}년 {viewStartDate.getMonth() + 1}월{" "}
|
||||||
{viewStartDate.getDate()}일 ~{" "}
|
{viewStartDate.getDate()}일 ~ {viewEndDate.getMonth() + 1}월{" "}
|
||||||
{viewEndDate.getMonth() + 1}월 {viewEndDate.getDate()}일
|
{viewEndDate.getDate()}일
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오른쪽 컨트롤 */}
|
{/* 오른쪽 컨트롤 */}
|
||||||
<div className="flex items-center gap-1 sm:gap-2">
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
|
{/* 충돌 카운트 표시 */}
|
||||||
|
{config.showConflicts !== false && conflictIds.size > 0 && (
|
||||||
|
<span className="rounded-full bg-destructive/10 px-1.5 py-0.5 text-[9px] font-medium text-destructive sm:px-2 sm:text-[10px]">
|
||||||
|
충돌 {conflictIds.size}건
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 줌 컨트롤 */}
|
{/* 줌 컨트롤 */}
|
||||||
{config.showZoomControls !== false && (
|
{config.showZoomControls !== false && (
|
||||||
<div className="flex items-center gap-0.5 sm:gap-1">
|
<div className="flex items-center gap-0.5 sm:gap-1">
|
||||||
|
|
@ -355,7 +416,10 @@ export function TimelineSchedulerComponent({
|
||||||
<ZoomOut className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
<ZoomOut className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<span className="min-w-[20px] text-center text-[10px] text-muted-foreground sm:min-w-[24px] sm:text-xs">
|
<span className="min-w-[20px] text-center text-[10px] text-muted-foreground sm:min-w-[24px] sm:text-xs">
|
||||||
{zoomLevelOptions.find((o) => o.value === zoomLevel)?.label}
|
{
|
||||||
|
zoomLevelOptions.find((o) => o.value === zoomLevel)
|
||||||
|
?.label
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -385,15 +449,8 @@ export function TimelineSchedulerComponent({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 타임라인 본문 */}
|
{/* 타임라인 본문 (스크롤 영역) */}
|
||||||
<div
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
className="overflow-auto"
|
|
||||||
style={{
|
|
||||||
height: config.showToolbar !== false
|
|
||||||
? `calc(100% - 48px)`
|
|
||||||
: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="min-w-max">
|
<div className="min-w-max">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<TimelineHeader
|
<TimelineHeader
|
||||||
|
|
@ -420,17 +477,23 @@ export function TimelineSchedulerComponent({
|
||||||
cellWidth={cellWidth}
|
cellWidth={cellWidth}
|
||||||
resourceColumnWidth={resourceColumnWidth}
|
resourceColumnWidth={resourceColumnWidth}
|
||||||
config={config}
|
config={config}
|
||||||
|
conflictIds={conflictIds}
|
||||||
onScheduleClick={handleScheduleClick}
|
onScheduleClick={handleScheduleClick}
|
||||||
onCellClick={handleCellClick}
|
onCellClick={handleCellClick}
|
||||||
onDragStart={handleDragStart}
|
onDragComplete={handleDragComplete}
|
||||||
onDragEnd={handleDragEnd}
|
onResizeComplete={handleResizeComplete}
|
||||||
onResizeStart={handleResizeStart}
|
|
||||||
onResizeEnd={handleResizeEnd}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 범례 */}
|
||||||
|
{showLegend && (
|
||||||
|
<div className="shrink-0">
|
||||||
|
<TimelineLegend config={config} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,54 +2,44 @@
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Resource, ScheduleItem, ZoomLevel, TimelineSchedulerConfig } from "../types";
|
import {
|
||||||
|
Resource,
|
||||||
|
ScheduleItem,
|
||||||
|
ZoomLevel,
|
||||||
|
TimelineSchedulerConfig,
|
||||||
|
} from "../types";
|
||||||
import { ScheduleBar } from "./ScheduleBar";
|
import { ScheduleBar } from "./ScheduleBar";
|
||||||
|
|
||||||
interface ResourceRowProps {
|
interface ResourceRowProps {
|
||||||
/** 리소스 */
|
|
||||||
resource: Resource;
|
resource: Resource;
|
||||||
/** 해당 리소스의 스케줄 목록 */
|
|
||||||
schedules: ScheduleItem[];
|
schedules: ScheduleItem[];
|
||||||
/** 시작 날짜 */
|
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
/** 종료 날짜 */
|
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
/** 줌 레벨 */
|
|
||||||
zoomLevel: ZoomLevel;
|
zoomLevel: ZoomLevel;
|
||||||
/** 행 높이 */
|
|
||||||
rowHeight: number;
|
rowHeight: number;
|
||||||
/** 셀 너비 */
|
|
||||||
cellWidth: number;
|
cellWidth: number;
|
||||||
/** 리소스 컬럼 너비 */
|
|
||||||
resourceColumnWidth: number;
|
resourceColumnWidth: number;
|
||||||
/** 설정 */
|
|
||||||
config: TimelineSchedulerConfig;
|
config: TimelineSchedulerConfig;
|
||||||
/** 스케줄 클릭 */
|
/** 충돌 스케줄 ID 목록 */
|
||||||
|
conflictIds?: Set<string>;
|
||||||
onScheduleClick?: (schedule: ScheduleItem) => void;
|
onScheduleClick?: (schedule: ScheduleItem) => void;
|
||||||
/** 빈 셀 클릭 */
|
|
||||||
onCellClick?: (resourceId: string, date: Date) => void;
|
onCellClick?: (resourceId: string, date: Date) => void;
|
||||||
/** 드래그 시작 */
|
/** 드래그 완료: deltaX(픽셀) 전달 */
|
||||||
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void;
|
onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void;
|
||||||
/** 드래그 종료 */
|
/** 리사이즈 완료: direction + deltaX(픽셀) 전달 */
|
||||||
onDragEnd?: () => void;
|
onResizeComplete?: (
|
||||||
/** 리사이즈 시작 */
|
schedule: ScheduleItem,
|
||||||
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void;
|
direction: "start" | "end",
|
||||||
/** 리사이즈 종료 */
|
deltaX: number
|
||||||
onResizeEnd?: () => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 날짜 차이 계산 (일수)
|
|
||||||
*/
|
|
||||||
const getDaysDiff = (start: Date, end: Date): number => {
|
const getDaysDiff = (start: Date, end: Date): number => {
|
||||||
const startTime = new Date(start).setHours(0, 0, 0, 0);
|
const startTime = new Date(start).setHours(0, 0, 0, 0);
|
||||||
const endTime = new Date(end).setHours(0, 0, 0, 0);
|
const endTime = new Date(end).setHours(0, 0, 0, 0);
|
||||||
return Math.round((endTime - startTime) / (1000 * 60 * 60 * 24));
|
return Math.round((endTime - startTime) / (1000 * 60 * 60 * 24));
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 날짜 범위 내의 셀 개수 계산
|
|
||||||
*/
|
|
||||||
const getCellCount = (startDate: Date, endDate: Date): number => {
|
const getCellCount = (startDate: Date, endDate: Date): number => {
|
||||||
return getDaysDiff(startDate, endDate) + 1;
|
return getDaysDiff(startDate, endDate) + 1;
|
||||||
};
|
};
|
||||||
|
|
@ -64,20 +54,18 @@ export function ResourceRow({
|
||||||
cellWidth,
|
cellWidth,
|
||||||
resourceColumnWidth,
|
resourceColumnWidth,
|
||||||
config,
|
config,
|
||||||
|
conflictIds,
|
||||||
onScheduleClick,
|
onScheduleClick,
|
||||||
onCellClick,
|
onCellClick,
|
||||||
onDragStart,
|
onDragComplete,
|
||||||
onDragEnd,
|
onResizeComplete,
|
||||||
onResizeStart,
|
|
||||||
onResizeEnd,
|
|
||||||
}: ResourceRowProps) {
|
}: ResourceRowProps) {
|
||||||
// 총 셀 개수
|
const totalCells = useMemo(
|
||||||
const totalCells = useMemo(() => getCellCount(startDate, endDate), [startDate, endDate]);
|
() => getCellCount(startDate, endDate),
|
||||||
|
[startDate, endDate]
|
||||||
// 총 그리드 너비
|
);
|
||||||
const gridWidth = totalCells * cellWidth;
|
const gridWidth = totalCells * cellWidth;
|
||||||
|
|
||||||
// 오늘 날짜
|
|
||||||
const today = useMemo(() => {
|
const today = useMemo(() => {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
d.setHours(0, 0, 0, 0);
|
d.setHours(0, 0, 0, 0);
|
||||||
|
|
@ -92,21 +80,26 @@ export function ResourceRow({
|
||||||
scheduleStart.setHours(0, 0, 0, 0);
|
scheduleStart.setHours(0, 0, 0, 0);
|
||||||
scheduleEnd.setHours(0, 0, 0, 0);
|
scheduleEnd.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
// 시작 위치 계산
|
|
||||||
const startOffset = getDaysDiff(startDate, scheduleStart);
|
const startOffset = getDaysDiff(startDate, scheduleStart);
|
||||||
const left = Math.max(0, startOffset * cellWidth);
|
const left = Math.max(0, startOffset * cellWidth);
|
||||||
|
|
||||||
// 너비 계산
|
|
||||||
const durationDays = getDaysDiff(scheduleStart, scheduleEnd) + 1;
|
const durationDays = getDaysDiff(scheduleStart, scheduleEnd) + 1;
|
||||||
const visibleStartOffset = Math.max(0, startOffset);
|
const visibleStartOffset = Math.max(0, startOffset);
|
||||||
const visibleEndOffset = Math.min(
|
const visibleEndOffset = Math.min(
|
||||||
totalCells,
|
totalCells,
|
||||||
startOffset + durationDays
|
startOffset + durationDays
|
||||||
);
|
);
|
||||||
const width = Math.max(cellWidth, (visibleEndOffset - visibleStartOffset) * cellWidth);
|
const width = Math.max(
|
||||||
|
cellWidth,
|
||||||
|
(visibleEndOffset - visibleStartOffset) * cellWidth
|
||||||
|
);
|
||||||
|
|
||||||
|
// 시작일 = 종료일이면 마일스톤
|
||||||
|
const isMilestone = schedule.startDate === schedule.endDate;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
schedule,
|
schedule,
|
||||||
|
isMilestone,
|
||||||
position: {
|
position: {
|
||||||
left: resourceColumnWidth + left,
|
left: resourceColumnWidth + left,
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|
@ -115,9 +108,15 @@ export function ResourceRow({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [schedules, startDate, cellWidth, resourceColumnWidth, rowHeight, totalCells]);
|
}, [
|
||||||
|
schedules,
|
||||||
|
startDate,
|
||||||
|
cellWidth,
|
||||||
|
resourceColumnWidth,
|
||||||
|
rowHeight,
|
||||||
|
totalCells,
|
||||||
|
]);
|
||||||
|
|
||||||
// 그리드 셀 클릭 핸들러
|
|
||||||
const handleGridClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleGridClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (!onCellClick) return;
|
if (!onCellClick) return;
|
||||||
|
|
||||||
|
|
@ -142,7 +141,9 @@ export function ResourceRow({
|
||||||
style={{ width: resourceColumnWidth }}
|
style={{ width: resourceColumnWidth }}
|
||||||
>
|
>
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<div className="truncate text-[10px] font-medium sm:text-sm">{resource.name}</div>
|
<div className="truncate text-[10px] font-medium sm:text-sm">
|
||||||
|
{resource.name}
|
||||||
|
</div>
|
||||||
{resource.group && (
|
{resource.group && (
|
||||||
<div className="truncate text-[9px] text-muted-foreground sm:text-xs">
|
<div className="truncate text-[9px] text-muted-foreground sm:text-xs">
|
||||||
{resource.group}
|
{resource.group}
|
||||||
|
|
@ -162,7 +163,8 @@ export function ResourceRow({
|
||||||
{Array.from({ length: totalCells }).map((_, idx) => {
|
{Array.from({ length: totalCells }).map((_, idx) => {
|
||||||
const cellDate = new Date(startDate);
|
const cellDate = new Date(startDate);
|
||||||
cellDate.setDate(cellDate.getDate() + idx);
|
cellDate.setDate(cellDate.getDate() + idx);
|
||||||
const isWeekend = cellDate.getDay() === 0 || cellDate.getDay() === 6;
|
const isWeekend =
|
||||||
|
cellDate.getDay() === 0 || cellDate.getDay() === 6;
|
||||||
const isToday = cellDate.getTime() === today.getTime();
|
const isToday = cellDate.getTime() === today.getTime();
|
||||||
const isMonthStart = cellDate.getDate() === 1;
|
const isMonthStart = cellDate.getDate() === 1;
|
||||||
|
|
||||||
|
|
@ -182,22 +184,22 @@ export function ResourceRow({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 스케줄 바들 */}
|
{/* 스케줄 바들 */}
|
||||||
{schedulePositions.map(({ schedule, position }) => (
|
{schedulePositions.map(({ schedule, position, isMilestone }) => (
|
||||||
<ScheduleBar
|
<ScheduleBar
|
||||||
key={schedule.id}
|
key={schedule.id}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
position={{
|
position={{
|
||||||
...position,
|
...position,
|
||||||
left: position.left - resourceColumnWidth, // 상대 위치
|
left: position.left - resourceColumnWidth,
|
||||||
}}
|
}}
|
||||||
config={config}
|
config={config}
|
||||||
draggable={config.draggable}
|
draggable={config.draggable}
|
||||||
resizable={config.resizable}
|
resizable={config.resizable}
|
||||||
|
hasConflict={conflictIds?.has(schedule.id) ?? false}
|
||||||
|
isMilestone={isMilestone}
|
||||||
onClick={() => onScheduleClick?.(schedule)}
|
onClick={() => onScheduleClick?.(schedule)}
|
||||||
onDragStart={onDragStart}
|
onDragComplete={onDragComplete}
|
||||||
onDragEnd={onDragEnd}
|
onResizeComplete={onResizeComplete}
|
||||||
onResizeStart={onResizeStart}
|
|
||||||
onResizeEnd={onResizeEnd}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,79 +2,99 @@
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef } from "react";
|
import React, { useState, useCallback, useRef } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ScheduleItem, ScheduleBarPosition, TimelineSchedulerConfig } from "../types";
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ScheduleItem,
|
||||||
|
ScheduleBarPosition,
|
||||||
|
TimelineSchedulerConfig,
|
||||||
|
} from "../types";
|
||||||
import { statusOptions } from "../config";
|
import { statusOptions } from "../config";
|
||||||
|
|
||||||
interface ScheduleBarProps {
|
interface ScheduleBarProps {
|
||||||
/** 스케줄 항목 */
|
|
||||||
schedule: ScheduleItem;
|
schedule: ScheduleItem;
|
||||||
/** 위치 정보 */
|
|
||||||
position: ScheduleBarPosition;
|
position: ScheduleBarPosition;
|
||||||
/** 설정 */
|
|
||||||
config: TimelineSchedulerConfig;
|
config: TimelineSchedulerConfig;
|
||||||
/** 드래그 가능 여부 */
|
|
||||||
draggable?: boolean;
|
draggable?: boolean;
|
||||||
/** 리사이즈 가능 여부 */
|
|
||||||
resizable?: boolean;
|
resizable?: boolean;
|
||||||
/** 클릭 이벤트 */
|
hasConflict?: boolean;
|
||||||
|
isMilestone?: boolean;
|
||||||
onClick?: (schedule: ScheduleItem) => void;
|
onClick?: (schedule: ScheduleItem) => void;
|
||||||
/** 드래그 시작 */
|
/** 드래그 완료 시 deltaX(픽셀) 전달 */
|
||||||
onDragStart?: (schedule: ScheduleItem, e: React.MouseEvent) => void;
|
onDragComplete?: (schedule: ScheduleItem, deltaX: number) => void;
|
||||||
/** 드래그 중 */
|
/** 리사이즈 완료 시 direction과 deltaX(픽셀) 전달 */
|
||||||
onDrag?: (deltaX: number, deltaY: number) => void;
|
onResizeComplete?: (
|
||||||
/** 드래그 종료 */
|
schedule: ScheduleItem,
|
||||||
onDragEnd?: () => void;
|
direction: "start" | "end",
|
||||||
/** 리사이즈 시작 */
|
deltaX: number
|
||||||
onResizeStart?: (schedule: ScheduleItem, direction: "start" | "end", e: React.MouseEvent) => void;
|
) => void;
|
||||||
/** 리사이즈 중 */
|
|
||||||
onResize?: (deltaX: number, direction: "start" | "end") => void;
|
|
||||||
/** 리사이즈 종료 */
|
|
||||||
onResizeEnd?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 드래그/리사이즈 판정 최소 이동 거리 (px)
|
||||||
|
const MIN_MOVE_THRESHOLD = 5;
|
||||||
|
|
||||||
export function ScheduleBar({
|
export function ScheduleBar({
|
||||||
schedule,
|
schedule,
|
||||||
position,
|
position,
|
||||||
config,
|
config,
|
||||||
draggable = true,
|
draggable = true,
|
||||||
resizable = true,
|
resizable = true,
|
||||||
|
hasConflict = false,
|
||||||
|
isMilestone = false,
|
||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragComplete,
|
||||||
onDragEnd,
|
onResizeComplete,
|
||||||
onResizeStart,
|
|
||||||
onResizeEnd,
|
|
||||||
}: ScheduleBarProps) {
|
}: ScheduleBarProps) {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const [dragOffset, setDragOffset] = useState(0);
|
||||||
|
const [resizeOffset, setResizeOffset] = useState(0);
|
||||||
|
const [resizeDir, setResizeDir] = useState<"start" | "end">("end");
|
||||||
const barRef = useRef<HTMLDivElement>(null);
|
const barRef = useRef<HTMLDivElement>(null);
|
||||||
|
const startXRef = useRef(0);
|
||||||
|
const movedRef = useRef(false);
|
||||||
|
|
||||||
// 상태에 따른 색상
|
const statusColor =
|
||||||
const statusColor = schedule.color ||
|
schedule.color ||
|
||||||
config.statusColors?.[schedule.status] ||
|
config.statusColors?.[schedule.status] ||
|
||||||
statusOptions.find((s) => s.value === schedule.status)?.color ||
|
statusOptions.find((s) => s.value === schedule.status)?.color ||
|
||||||
"#3b82f6";
|
"#3b82f6";
|
||||||
|
|
||||||
// 진행률 바 너비
|
const progressWidth =
|
||||||
const progressWidth = config.showProgress && schedule.progress !== undefined
|
config.showProgress && schedule.progress !== undefined
|
||||||
? `${schedule.progress}%`
|
? `${schedule.progress}%`
|
||||||
: "0%";
|
: "0%";
|
||||||
|
|
||||||
// 드래그 시작 핸들러
|
const isEditable = config.editable !== false;
|
||||||
|
|
||||||
|
// ────────── 드래그 핸들러 ──────────
|
||||||
const handleMouseDown = useCallback(
|
const handleMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (!draggable || isResizing) return;
|
if (!draggable || isResizing || !isEditable) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
startXRef.current = e.clientX;
|
||||||
|
movedRef.current = false;
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
onDragStart?.(schedule, e);
|
setDragOffset(0);
|
||||||
|
|
||||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
// 드래그 중 로직은 부모에서 처리
|
const delta = moveEvent.clientX - startXRef.current;
|
||||||
|
if (Math.abs(delta) > MIN_MOVE_THRESHOLD) {
|
||||||
|
movedRef.current = true;
|
||||||
|
}
|
||||||
|
setDragOffset(delta);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = (upEvent: MouseEvent) => {
|
||||||
|
const finalDelta = upEvent.clientX - startXRef.current;
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
onDragEnd?.();
|
setDragOffset(0);
|
||||||
|
|
||||||
|
if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) {
|
||||||
|
onDragComplete?.(schedule, finalDelta);
|
||||||
|
}
|
||||||
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
};
|
};
|
||||||
|
|
@ -82,25 +102,39 @@ export function ScheduleBar({
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
},
|
},
|
||||||
[draggable, isResizing, schedule, onDragStart, onDragEnd]
|
[draggable, isResizing, isEditable, schedule, onDragComplete]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 리사이즈 시작 핸들러
|
// ────────── 리사이즈 핸들러 ──────────
|
||||||
const handleResizeStart = useCallback(
|
const handleResizeMouseDown = useCallback(
|
||||||
(direction: "start" | "end", e: React.MouseEvent) => {
|
(direction: "start" | "end", e: React.MouseEvent) => {
|
||||||
if (!resizable) return;
|
if (!resizable || !isEditable) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
|
startXRef.current = e.clientX;
|
||||||
|
movedRef.current = false;
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
onResizeStart?.(schedule, direction, e);
|
setResizeOffset(0);
|
||||||
|
setResizeDir(direction);
|
||||||
|
|
||||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
// 리사이즈 중 로직은 부모에서 처리
|
const delta = moveEvent.clientX - startXRef.current;
|
||||||
|
if (Math.abs(delta) > MIN_MOVE_THRESHOLD) {
|
||||||
|
movedRef.current = true;
|
||||||
|
}
|
||||||
|
setResizeOffset(delta);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = (upEvent: MouseEvent) => {
|
||||||
|
const finalDelta = upEvent.clientX - startXRef.current;
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
onResizeEnd?.();
|
setResizeOffset(0);
|
||||||
|
|
||||||
|
if (movedRef.current && Math.abs(finalDelta) > MIN_MOVE_THRESHOLD) {
|
||||||
|
onResizeComplete?.(schedule, direction, finalDelta);
|
||||||
|
}
|
||||||
|
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
};
|
};
|
||||||
|
|
@ -108,19 +142,62 @@ export function ScheduleBar({
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
},
|
},
|
||||||
[resizable, schedule, onResizeStart, onResizeEnd]
|
[resizable, isEditable, schedule, onResizeComplete]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 클릭 핸들러
|
// ────────── 클릭 핸들러 ──────────
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (isDragging || isResizing) return;
|
if (movedRef.current) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick?.(schedule);
|
onClick?.(schedule);
|
||||||
},
|
},
|
||||||
[isDragging, isResizing, onClick, schedule]
|
[onClick, schedule]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ────────── 드래그/리사이즈 중 시각적 위치 계산 ──────────
|
||||||
|
let visualLeft = position.left;
|
||||||
|
let visualWidth = position.width;
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
visualLeft += dragOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isResizing) {
|
||||||
|
if (resizeDir === "start") {
|
||||||
|
visualLeft += resizeOffset;
|
||||||
|
visualWidth -= resizeOffset;
|
||||||
|
} else {
|
||||||
|
visualWidth += resizeOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
visualWidth = Math.max(10, visualWidth);
|
||||||
|
|
||||||
|
// ────────── 마일스톤 렌더링 (단일 날짜 마커) ──────────
|
||||||
|
if (isMilestone) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={barRef}
|
||||||
|
className="absolute flex cursor-pointer items-center justify-center"
|
||||||
|
style={{
|
||||||
|
left: visualLeft + position.width / 2 - 8,
|
||||||
|
top: position.top + position.height / 2 - 8,
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
title={schedule.title}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-2.5 rotate-45 shadow-sm transition-transform hover:scale-125 sm:h-3 sm:w-3"
|
||||||
|
style={{ backgroundColor: statusColor }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ────────── 일반 스케줄 바 렌더링 ──────────
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={barRef}
|
ref={barRef}
|
||||||
|
|
@ -128,19 +205,21 @@ export function ScheduleBar({
|
||||||
"absolute cursor-pointer rounded-md shadow-sm transition-shadow",
|
"absolute cursor-pointer rounded-md shadow-sm transition-shadow",
|
||||||
"hover:z-10 hover:shadow-md",
|
"hover:z-10 hover:shadow-md",
|
||||||
isDragging && "z-20 opacity-70 shadow-lg",
|
isDragging && "z-20 opacity-70 shadow-lg",
|
||||||
isResizing && "z-20",
|
isResizing && "z-20 opacity-80",
|
||||||
draggable && "cursor-grab",
|
draggable && isEditable && "cursor-grab",
|
||||||
isDragging && "cursor-grabbing"
|
isDragging && "cursor-grabbing",
|
||||||
|
hasConflict && "ring-2 ring-destructive ring-offset-1"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: position.left,
|
left: visualLeft,
|
||||||
top: position.top + 4,
|
top: position.top + 4,
|
||||||
width: position.width,
|
width: visualWidth,
|
||||||
height: position.height - 8,
|
height: position.height - 8,
|
||||||
backgroundColor: statusColor,
|
backgroundColor: statusColor,
|
||||||
}}
|
}}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
title={schedule.title}
|
||||||
>
|
>
|
||||||
{/* 진행률 바 */}
|
{/* 진행률 바 */}
|
||||||
{config.showProgress && schedule.progress !== undefined && (
|
{config.showProgress && schedule.progress !== undefined && (
|
||||||
|
|
@ -162,19 +241,26 @@ export function ScheduleBar({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 충돌 인디케이터 */}
|
||||||
|
{hasConflict && (
|
||||||
|
<div className="absolute -right-0.5 -top-0.5 sm:-right-1 sm:-top-1">
|
||||||
|
<AlertTriangle className="h-2.5 w-2.5 fill-destructive text-white sm:h-3 sm:w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 리사이즈 핸들 - 왼쪽 */}
|
{/* 리사이즈 핸들 - 왼쪽 */}
|
||||||
{resizable && (
|
{resizable && isEditable && (
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-0 left-0 top-0 w-1.5 cursor-ew-resize rounded-l-md hover:bg-white/20 sm:w-2"
|
className="absolute bottom-0 left-0 top-0 w-1.5 cursor-ew-resize rounded-l-md hover:bg-white/20 sm:w-2"
|
||||||
onMouseDown={(e) => handleResizeStart("start", e)}
|
onMouseDown={(e) => handleResizeMouseDown("start", e)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 리사이즈 핸들 - 오른쪽 */}
|
{/* 리사이즈 핸들 - 오른쪽 */}
|
||||||
{resizable && (
|
{resizable && isEditable && (
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-0 right-0 top-0 w-1.5 cursor-ew-resize rounded-r-md hover:bg-white/20 sm:w-2"
|
className="absolute bottom-0 right-0 top-0 w-1.5 cursor-ew-resize rounded-r-md hover:bg-white/20 sm:w-2"
|
||||||
onMouseDown={(e) => handleResizeStart("end", e)}
|
onMouseDown={(e) => handleResizeMouseDown("end", e)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { TimelineSchedulerConfig } from "../types";
|
||||||
|
import { statusOptions } from "../config";
|
||||||
|
|
||||||
|
interface TimelineLegendProps {
|
||||||
|
config: TimelineSchedulerConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelineLegend({ config }: TimelineLegendProps) {
|
||||||
|
const colors = config.statusColors || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-t bg-muted/20 px-2 py-1 sm:gap-3 sm:px-3 sm:py-1.5">
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground sm:text-xs">
|
||||||
|
범례:
|
||||||
|
</span>
|
||||||
|
{statusOptions.map((status) => (
|
||||||
|
<div key={status.value} className="flex items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="h-2 w-4 rounded-sm sm:h-2.5 sm:w-5"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
colors[status.value as keyof typeof colors] || status.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 마일스톤 범례 */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="flex h-2.5 w-4 items-center justify-center sm:h-3 sm:w-5">
|
||||||
|
<div className="h-1.5 w-1.5 rotate-45 bg-foreground/60 sm:h-2 sm:w-2" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
|
||||||
|
마일스톤
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 충돌 범례 */}
|
||||||
|
{config.showConflicts && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="h-2 w-4 rounded-sm ring-1.5 ring-destructive sm:h-2.5 sm:w-5" />
|
||||||
|
<span className="text-[9px] text-muted-foreground sm:text-[10px]">
|
||||||
|
충돌
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export { TimelineHeader } from "./TimelineHeader";
|
export { TimelineHeader } from "./TimelineHeader";
|
||||||
export { ScheduleBar } from "./ScheduleBar";
|
export { ScheduleBar } from "./ScheduleBar";
|
||||||
export { ResourceRow } from "./ResourceRow";
|
export { ResourceRow } from "./ResourceRow";
|
||||||
|
export { TimelineLegend } from "./TimelineLegend";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ScheduleItem } from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 같은 리소스에서 시간이 겹치는 스케줄을 감지
|
||||||
|
* @returns 충돌이 있는 스케줄 ID Set
|
||||||
|
*/
|
||||||
|
export function detectConflicts(schedules: ScheduleItem[]): Set<string> {
|
||||||
|
const conflictIds = new Set<string>();
|
||||||
|
|
||||||
|
// 리소스별로 그룹화
|
||||||
|
const byResource = new Map<string, ScheduleItem[]>();
|
||||||
|
for (const schedule of schedules) {
|
||||||
|
if (!byResource.has(schedule.resourceId)) {
|
||||||
|
byResource.set(schedule.resourceId, []);
|
||||||
|
}
|
||||||
|
byResource.get(schedule.resourceId)!.push(schedule);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리소스별 충돌 검사
|
||||||
|
for (const [, resourceSchedules] of byResource) {
|
||||||
|
if (resourceSchedules.length < 2) continue;
|
||||||
|
|
||||||
|
// 시작일 기준 정렬
|
||||||
|
const sorted = [...resourceSchedules].sort(
|
||||||
|
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
const aEnd = new Date(sorted[i].endDate).getTime();
|
||||||
|
|
||||||
|
for (let j = i + 1; j < sorted.length; j++) {
|
||||||
|
const bStart = new Date(sorted[j].startDate).getTime();
|
||||||
|
|
||||||
|
// 정렬되어 있으므로 aStart <= bStart
|
||||||
|
// 겹치는 조건: aEnd > bStart
|
||||||
|
if (aEnd > bStart) {
|
||||||
|
conflictIds.add(sorted[i].id);
|
||||||
|
conflictIds.add(sorted[j].id);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflictIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 날짜를 일수만큼 이동
|
||||||
|
*/
|
||||||
|
export function addDaysToDateString(dateStr: string, days: number): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
date.setDate(date.getDate() + days);
|
||||||
|
return date.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue