jskim-node #418

Merged
kjs merged 52 commits from jskim-node into main 2026-03-16 14:53:15 +09:00
7 changed files with 493 additions and 224 deletions
Showing only changes of commit 6505df8555 - Show all commits

View File

@ -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] 레지스트리 등록

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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>

View File

@ -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>
);
}

View File

@ -1,3 +1,4 @@
export { TimelineHeader } from "./TimelineHeader";
export { ScheduleBar } from "./ScheduleBar";
export { ResourceRow } from "./ResourceRow";
export { TimelineLegend } from "./TimelineLegend";

View File

@ -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];
}