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