"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ChevronLeft, ChevronRight, Calendar, Plus, Loader2, ZoomIn, ZoomOut, Package, Zap, RefreshCw, Download, Upload, Play, FileText, Send, Sparkles, Wand2, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { useVirtualizer } from "@tanstack/react-virtual"; import { TimelineSchedulerComponentProps, ScheduleItem, ZoomLevel, ToolbarAction, } from "./types"; import { useTimelineData } from "./hooks/useTimelineData"; import { TimelineHeader, ResourceRow, TimelineLegend, ItemTimelineCard, groupSchedulesByItem, SchedulePreviewDialog } from "./components"; import { zoomLevelOptions, defaultTimelineSchedulerConfig, statusOptions } from "./config"; import { detectConflicts, addDaysToDateString } from "./utils/conflictDetection"; import { v2EventBus, V2_EVENTS } from "@/lib/v2-core"; import { apiClient } from "@/lib/api/client"; // 가상 스크롤 활성화 임계값 (리소스 수) const VIRTUAL_THRESHOLD = 30; /** * 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(null); // ────────── 툴바 액션 다이얼로그 상태 (통합) ────────── const [actionDialog, setActionDialog] = useState<{ actionId: string; action: ToolbarAction; isLoading: boolean; isApplying: boolean; summary: any; previews: any[]; deletedSchedules: any[]; keptSchedules: any[]; preparedPayload: any; } | null>(null); const linkedFilterValuesRef = useRef([]); // ────────── 아이콘 맵 ────────── const TOOLBAR_ICONS: Record> = useMemo(() => ({ Zap, Package, Plus, Download, Upload, RefreshCw, Play, FileText, Send, Sparkles, Wand2, }), []); // ────────── linkedFilter 상태 ────────── const linkedFilter = config.linkedFilter; const hasLinkedFilter = !!linkedFilter; const [linkedFilterValues, setLinkedFilterValues] = useState([]); const [hasReceivedSelection, setHasReceivedSelection] = useState(false); // linkedFilter 이벤트 수신 useEffect(() => { if (!hasLinkedFilter) return; const handler = (event: any) => { if (linkedFilter!.sourceTableName && event.tableName !== linkedFilter!.sourceTableName) return; if (linkedFilter!.sourceComponentId && event.componentId !== linkedFilter!.sourceComponentId) return; const selectedRows: any[] = event.selectedRows || []; const sourceField = linkedFilter!.sourceField; const values = selectedRows .map((row: any) => String(row[sourceField] ?? "")) .filter((v: string) => v !== "" && v !== "undefined" && v !== "null"); const uniqueValues = [...new Set(values)]; setLinkedFilterValues(uniqueValues); setHasReceivedSelection(true); linkedFilterValuesRef.current = selectedRows; }; const unsubscribe = v2EventBus.subscribe(V2_EVENTS.TABLE_SELECTION_CHANGE, handler); return unsubscribe; }, [hasLinkedFilter, linkedFilter]); // 타임라인 데이터 훅 const { schedules: rawSchedules, resources, isLoading: hookLoading, error: hookError, zoomLevel, setZoomLevel, viewStartDate, viewEndDate, goToPrevious, goToNext, goToToday, updateSchedule, refresh: refreshTimeline, } = useTimelineData(config, externalSchedules, externalResources); // linkedFilter 적용: 선택된 값으로 스케줄 필터링 const schedules = useMemo(() => { if (!hasLinkedFilter) return rawSchedules; if (linkedFilterValues.length === 0) return []; const targetField = linkedFilter!.targetField; return rawSchedules.filter((s) => { const val = String((s.data as any)?.[targetField] ?? (s as any)[targetField] ?? ""); return linkedFilterValues.includes(val); }); }, [rawSchedules, hasLinkedFilter, linkedFilterValues, linkedFilter]); 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(); 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(); 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(); 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]); // ────────── 유효 툴바 액션 (config 기반 또는 하위호환 자동생성) ────────── const effectiveToolbarActions: ToolbarAction[] = useMemo(() => { if (config.toolbarActions && config.toolbarActions.length > 0) { return config.toolbarActions; } return []; }, [config.toolbarActions]); // ────────── 범용 액션: 미리보기 요청 ────────── const handleActionPreview = useCallback(async (action: ToolbarAction) => { let payload: any; if (action.dataSource === "linkedSelection") { const selectedRows = linkedFilterValuesRef.current; if (!selectedRows || selectedRows.length === 0) { toast.warning("좌측에서 항목을 선택해주세요"); return; } const groupField = action.payloadConfig?.groupByField || config.linkedFilter?.sourceField || "part_code"; const qtyField = action.payloadConfig?.quantityField || config.sourceConfig?.quantityField || "balance_qty"; const dateField = action.payloadConfig?.dueDateField || config.sourceConfig?.dueDateField || "due_date"; const nameField = action.payloadConfig?.nameField || config.sourceConfig?.groupNameField || "part_name"; const grouped = new Map(); selectedRows.forEach((row: any) => { const key = row[groupField] || ""; if (!key) return; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key)!.push(row); }); const items = Array.from(grouped.entries()).map(([code, rows]) => { const totalQty = rows.reduce((sum: number, r: any) => sum + (Number(r[qtyField]) || 0), 0); const dates = rows.map((r: any) => r[dateField]).filter(Boolean).sort(); const earliestDate = dates[0] || new Date().toISOString().split("T")[0]; const first = rows[0]; return { item_code: code, item_name: first[nameField] || first.item_name || code, required_qty: totalQty, earliest_due_date: typeof earliestDate === "string" ? earliestDate.split("T")[0] : earliestDate, hourly_capacity: Number(first.hourly_capacity) || undefined, daily_capacity: Number(first.daily_capacity) || undefined, }; }).filter((item) => item.required_qty > 0); if (items.length === 0) { toast.warning("선택된 항목의 잔량이 없습니다"); return; } payload = { items, options: { ...(config.staticFilters || {}), ...(action.payloadConfig?.extraOptions || {}), }, }; } else if (action.dataSource === "currentSchedules") { let targetSchedules = schedules; const filterField = action.payloadConfig?.scheduleFilterField; const filterValue = action.payloadConfig?.scheduleFilterValue; if (filterField && filterValue) { targetSchedules = schedules.filter((s) => { const val = (s.data as any)?.[filterField] || ""; return val === filterValue; }); } if (targetSchedules.length === 0) { toast.warning("대상 스케줄이 없습니다"); return; } const planIds = targetSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id)); if (planIds.length === 0) { toast.warning("유효한 스케줄 ID가 없습니다"); return; } payload = { plan_ids: planIds, options: action.payloadConfig?.extraOptions || {}, }; } setActionDialog({ actionId: action.id, action, isLoading: true, isApplying: false, summary: null, previews: [], deletedSchedules: [], keptSchedules: [], preparedPayload: payload, }); try { const response = await apiClient.post(action.previewApi, payload); if (response.data?.success) { setActionDialog((prev) => prev ? { ...prev, isLoading: false, summary: response.data.data.summary, previews: response.data.data.previews || [], deletedSchedules: response.data.data.deletedSchedules || [], keptSchedules: response.data.data.keptSchedules || [], } : null); } else { toast.error("미리보기 생성 실패", { description: response.data?.message }); setActionDialog(null); } } catch (err: any) { toast.error("미리보기 요청 실패", { description: err.message }); setActionDialog(null); } }, [config.linkedFilter, config.staticFilters, config.sourceConfig, schedules]); // ────────── 범용 액션: 확인 및 적용 ────────── const handleActionApply = useCallback(async () => { if (!actionDialog) return; const { action, preparedPayload } = actionDialog; setActionDialog((prev) => prev ? { ...prev, isApplying: true } : null); try { const response = await apiClient.post(action.applyApi, preparedPayload); if (response.data?.success) { const data = response.data.data; const summary = data.summary || data; toast.success(action.dialogTitle || "완료", { description: `신규: ${summary.new_count || summary.count || 0}건${summary.kept_count ? `, 유지: ${summary.kept_count}건` : ""}${summary.deleted_count ? `, 삭제: ${summary.deleted_count}건` : ""}`, }); setActionDialog(null); refreshTimeline(); } else { toast.error("실행 실패", { description: response.data?.message }); } } catch (err: any) { toast.error("실행 실패", { description: err.message }); } finally { setActionDialog((prev) => prev ? { ...prev, isApplying: false } : null); } }, [actionDialog, refreshTimeline]); // ────────── 하단 영역 높이 계산 (툴바 + 범례) ────────── const showToolbar = config.showToolbar !== false; const showLegend = config.showLegend !== false; // ────────── 가상 스크롤 ────────── const scrollContainerRef = useRef(null); const useVirtual = effectiveResources.length >= VIRTUAL_THRESHOLD; const virtualizer = useVirtualizer({ count: effectiveResources.length, getScrollElement: () => scrollContainerRef.current, estimateSize: () => rowHeight, overscan: 5, }); // ────────── 디자인 모드 플레이스홀더 ────────── if (isDesignMode) { return (

타임라인 스케줄러

{config.selectedTable ? `테이블: ${config.selectedTable}` : "테이블을 선택하세요"}

); } // ────────── 로딩 상태 ────────── if (isLoading) { return (
로딩 중...
); } // ────────── 에러 상태 ────────── if (error) { return (

오류 발생

{error}

); } // ────────── linkedFilter 빈 상태 (itemGrouped가 아닌 경우만 early return) ────────── // itemGrouped 모드에서는 툴바를 항상 보여주기 위해 여기서 return하지 않음 if (config.viewMode !== "itemGrouped") { if (hasLinkedFilter && !hasReceivedSelection) { const emptyMsg = linkedFilter?.emptyMessage || "좌측 목록에서 품목 또는 수주를 선택하세요"; return (

{emptyMsg}

선택한 항목에 대한 생산계획 타임라인이 여기에 표시됩니다

); } if (hasLinkedFilter && hasReceivedSelection && schedules.length === 0) { return (

선택한 항목에 대한 스케줄이 없습니다

다른 품목을 선택하거나 스케줄을 생성해 주세요

); } } // ────────── 데이터 없음 (linkedFilter 없고 itemGrouped가 아닌 경우) ────────── if (schedules.length === 0 && config.viewMode !== "itemGrouped") { return (

스케줄 데이터가 없습니다

좌측 테이블에서 품목을 선택하거나,
스케줄 생성 버튼을 눌러 스케줄을 생성하세요

); } // ────────── 품목 그룹 모드 렌더링 ────────── if (config.viewMode === "itemGrouped") { const itemGroups = groupSchedulesByItem(schedules); return (
{/* 툴바: 액션 버튼 + 네비게이션 */} {showToolbar && (
{/* 네비게이션 */}
{config.showNavigation !== false && ( <> )} {viewStartDate.toLocaleDateString("ko-KR")} ~ {viewEndDate.toLocaleDateString("ko-KR")}
{/* 줌 + 액션 버튼 */}
{config.showZoomControls !== false && ( <> {zoomLevelOptions.find((o) => o.value === zoomLevel)?.label}
)} {effectiveToolbarActions.map((action) => { if (action.showWhen) { const matches = Object.entries(action.showWhen).every( ([key, value]) => config.staticFilters?.[key] === value ); if (!matches) return null; } const IconComp = TOOLBAR_ICONS[action.icon || "Zap"] || Zap; return ( ); })}
)} {/* 범례 */} {showLegend && (
생산 상태: {statusOptions.map((s) => (
{s.label}
))} 납기:
납기일
)} {/* 품목별 카드 목록 또는 빈 상태 */} {itemGroups.length > 0 ? (
{itemGroups.map((group) => ( ))}
) : (
{hasLinkedFilter && !hasReceivedSelection ? ( <>

{linkedFilter?.emptyMessage || "좌측 목록에서 품목을 선택하세요"}

선택한 항목에 대한 생산계획 타임라인이 여기에 표시됩니다

) : hasLinkedFilter && hasReceivedSelection ? ( <>

선택한 항목에 대한 스케줄이 없습니다

위 "자동 스케줄 생성" 버튼으로 생산계획을 생성하세요

) : ( <>

스케줄 데이터가 없습니다

)}
)} {/* 범용 액션 미리보기 다이얼로그 */} {actionDialog && ( { if (!open) setActionDialog(null); }} isLoading={actionDialog.isLoading} summary={actionDialog.summary} previews={actionDialog.previews} deletedSchedules={actionDialog.deletedSchedules} keptSchedules={actionDialog.keptSchedules} onConfirm={handleActionApply} isApplying={actionDialog.isApplying} title={actionDialog.action.dialogTitle} description={actionDialog.action.dialogDescription} /> )}
); } // ────────── 메인 렌더링 (리소스 기반) ────────── return (
{/* 툴바 */} {showToolbar && (
{/* 네비게이션 */}
{config.showNavigation !== false && ( <> )} {/* 날짜 범위 표시 */} {viewStartDate.getFullYear()}년 {viewStartDate.getMonth() + 1}월{" "} {viewStartDate.getDate()}일 ~ {viewEndDate.getMonth() + 1}월{" "} {viewEndDate.getDate()}일
{/* 오른쪽 컨트롤 */}
{/* 충돌 카운트 표시 */} {config.showConflicts !== false && conflictIds.size > 0 && ( 충돌 {conflictIds.size}건 )} {/* 줌 컨트롤 */} {config.showZoomControls !== false && (
{ zoomLevelOptions.find((o) => o.value === zoomLevel) ?.label }
)} {/* 추가 버튼 */} {config.showAddButton !== false && config.editable && ( )}
)} {/* 타임라인 본문 (스크롤 영역) */}
{/* 헤더 */} {/* 리소스 행들 - 30개 이상이면 가상 스크롤 */} {useVirtual ? (
{virtualizer.getVirtualItems().map((virtualRow) => { const resource = effectiveResources[virtualRow.index]; return (
); })}
) : (
{effectiveResources.map((resource) => ( ))}
)}
{/* 범례 */} {showLegend && (
)}
); }