ERP-node/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx

500 lines
16 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useCallback, useMemo, useRef } from "react";
import {
ChevronLeft,
ChevronRight,
Calendar,
Plus,
Loader2,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import {
TimelineSchedulerComponentProps,
ScheduleItem,
ZoomLevel,
} from "./types";
import { useTimelineData } from "./hooks/useTimelineData";
import { TimelineHeader, ResourceRow, TimelineLegend } from "./components";
import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config";
import { detectConflicts, addDaysToDateString } from "./utils/conflictDetection";
/**
* v2-timeline-scheduler
*
* /
*/
export function TimelineSchedulerComponent({
config,
isDesignMode = false,
formData,
externalSchedules,
externalResources,
isLoading: externalLoading,
error: externalError,
componentId,
onDragEnd,
onResizeEnd,
onScheduleClick,
onCellClick,
onAddSchedule,
}: TimelineSchedulerComponentProps) {
const containerRef = useRef<HTMLDivElement>(null);
// 타임라인 데이터 훅
const {
schedules,
resources,
isLoading: hookLoading,
error: hookError,
zoomLevel,
setZoomLevel,
viewStartDate,
viewEndDate,
goToPrevious,
goToNext,
goToToday,
updateSchedule,
} = useTimelineData(config, externalSchedules, externalResources);
const isLoading = externalLoading ?? hookLoading;
const error = externalError ?? hookError;
// 설정값
const rowHeight =
config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!;
const headerHeight =
config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!;
const resourceColumnWidth =
config.resourceColumnWidth ||
defaultTimelineSchedulerConfig.resourceColumnWidth!;
const cellWidthConfig =
config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!;
const cellWidth = cellWidthConfig[zoomLevel] || 60;
// 리소스 자동 생성 (리소스 테이블 미설정 시 스케줄 데이터에서 추출)
const effectiveResources = useMemo(() => {
if (resources.length > 0) return resources;
const uniqueResourceIds = new Set<string>();
schedules.forEach((s) => {
if (s.resourceId) uniqueResourceIds.add(s.resourceId);
});
return Array.from(uniqueResourceIds).map((id) => ({ id, name: id }));
}, [resources, schedules]);
// 리소스별 스케줄 그룹화
const schedulesByResource = useMemo(() => {
const grouped = new Map<string, ScheduleItem[]>();
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) {
grouped.get(firstResource.id)?.push(schedule);
}
}
});
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 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 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
);
if (resource && onScheduleClick) {
onScheduleClick({ schedule, resource });
}
},
[effectiveResources, onScheduleClick]
);
// ────────── 빈 셀 클릭 ──────────
const handleCellClick = useCallback(
(resourceId: string, date: Date) => {
if (onCellClick) {
onCellClick({
resourceId,
date: date.toISOString().split("T")[0],
});
}
},
[onCellClick]
);
// ────────── 드래그 완료 (핵심 로직) ──────────
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 handleResizeComplete = useCallback(
async (
schedule: ScheduleItem,
direction: "start" | "end",
deltaX: number
) => {
let daysPerCell = 1;
if (zoomLevel === "week") daysPerCell = 7;
if (zoomLevel === "month") daysPerCell = 30;
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 handleAddClick = useCallback(() => {
if (onAddSchedule && effectiveResources.length > 0) {
onAddSchedule(
effectiveResources[0].id,
new Date().toISOString().split("T")[0]
);
}
}, [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">
<div className="text-center text-muted-foreground">
<Calendar className="mx-auto mb-2 h-6 w-6 sm:h-8 sm:w-8" />
<p className="text-xs font-medium sm:text-sm"> </p>
<p className="mt-1 text-[10px] sm:text-xs">
{config.selectedTable
? `테이블: ${config.selectedTable}`
: "테이블을 선택하세요"}
</p>
</div>
</div>
);
}
// ────────── 로딩 상태 ──────────
if (isLoading) {
return (
<div
className="flex w-full items-center justify-center rounded-lg bg-muted/10"
style={{ height: config.height || 500 }}
>
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin sm:h-5 sm:w-5" />
<span className="text-xs sm:text-sm"> ...</span>
</div>
</div>
);
}
// ────────── 에러 상태 ──────────
if (error) {
return (
<div
className="flex w-full items-center justify-center rounded-lg bg-destructive/10"
style={{ height: config.height || 500 }}
>
<div className="text-center text-destructive">
<p className="text-xs font-medium sm:text-sm"> </p>
<p className="mt-1 text-[10px] sm:text-xs">{error}</p>
</div>
</div>
);
}
// ────────── 데이터 없음 ──────────
if (schedules.length === 0) {
return (
<div
className="flex w-full items-center justify-center rounded-lg border bg-muted/10"
style={{ height: config.height || 500 }}
>
<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="mt-1.5 max-w-[200px] text-[10px] sm:mt-2 sm:text-xs">
,
<br />
</p>
</div>
</div>
);
}
// ────────── 메인 렌더링 ──────────
return (
<div
ref={containerRef}
className="flex w-full flex-col overflow-hidden rounded-lg border bg-background"
style={{
height: config.height || 500,
maxHeight: config.maxHeight,
}}
>
{/* 툴바 */}
{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 && (
<>
<Button
variant="ghost"
size="sm"
onClick={goToPrevious}
className="h-6 px-1.5 sm:h-7 sm:px-2"
>
<ChevronLeft className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={goToToday}
className="h-6 px-1.5 text-xs sm:h-7 sm:px-2 sm:text-sm"
>
</Button>
<Button
variant="ghost"
size="sm"
onClick={goToNext}
className="h-6 px-1.5 sm:h-7 sm:px-2"
>
<ChevronRight className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</Button>
</>
)}
{/* 날짜 범위 표시 */}
<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()}
</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">
<Button
variant="ghost"
size="sm"
onClick={handleZoomOut}
disabled={zoomLevel === "month"}
className="h-6 px-1.5 sm:h-7 sm:px-2"
>
<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
}
</span>
<Button
variant="ghost"
size="sm"
onClick={handleZoomIn}
disabled={zoomLevel === "day"}
className="h-6 px-1.5 sm:h-7 sm:px-2"
>
<ZoomIn className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</Button>
</div>
)}
{/* 추가 버튼 */}
{config.showAddButton !== false && config.editable && (
<Button
variant="default"
size="sm"
onClick={handleAddClick}
className="h-6 text-xs sm:h-7 sm:text-sm"
>
<Plus className="mr-0.5 h-3.5 w-3.5 sm:mr-1 sm:h-4 sm:w-4" />
</Button>
)}
</div>
</div>
)}
{/* 타임라인 본문 (스크롤 영역) */}
<div className="min-h-0 flex-1 overflow-auto">
<div className="min-w-max">
{/* 헤더 */}
<TimelineHeader
startDate={viewStartDate}
endDate={viewEndDate}
zoomLevel={zoomLevel}
cellWidth={cellWidth}
headerHeight={headerHeight}
resourceColumnWidth={resourceColumnWidth}
showTodayLine={config.showTodayLine}
/>
{/* 리소스 행들 */}
<div>
{effectiveResources.map((resource) => (
<ResourceRow
key={resource.id}
resource={resource}
schedules={schedulesByResource.get(resource.id) || []}
startDate={viewStartDate}
endDate={viewEndDate}
zoomLevel={zoomLevel}
rowHeight={rowHeight}
cellWidth={cellWidth}
resourceColumnWidth={resourceColumnWidth}
config={config}
conflictIds={conflictIds}
onScheduleClick={handleScheduleClick}
onCellClick={handleCellClick}
onDragComplete={handleDragComplete}
onResizeComplete={handleResizeComplete}
/>
))}
</div>
</div>
</div>
{/* 범례 */}
{showLegend && (
<div className="shrink-0">
<TimelineLegend config={config} />
</div>
)}
</div>
);
}