2026-02-02 10:46:01 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2026-03-16 14:00:07 +09:00
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
2026-02-02 10:46:01 +09:00
|
|
|
import {
|
|
|
|
|
ChevronLeft,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
Calendar,
|
|
|
|
|
Plus,
|
|
|
|
|
Loader2,
|
|
|
|
|
ZoomIn,
|
|
|
|
|
ZoomOut,
|
2026-03-16 14:00:07 +09:00
|
|
|
Package,
|
|
|
|
|
Zap,
|
|
|
|
|
RefreshCw,
|
2026-03-16 14:51:34 +09:00
|
|
|
Download,
|
|
|
|
|
Upload,
|
|
|
|
|
Play,
|
|
|
|
|
FileText,
|
|
|
|
|
Send,
|
|
|
|
|
Sparkles,
|
|
|
|
|
Wand2,
|
2026-02-02 10:46:01 +09:00
|
|
|
} from "lucide-react";
|
2026-03-16 14:51:34 +09:00
|
|
|
import { cn } from "@/lib/utils";
|
2026-02-02 10:46:01 +09:00
|
|
|
import { Button } from "@/components/ui/button";
|
2026-03-16 10:40:10 +09:00
|
|
|
import { toast } from "sonner";
|
2026-03-16 14:00:07 +09:00
|
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
2026-02-02 10:46:01 +09:00
|
|
|
import {
|
|
|
|
|
TimelineSchedulerComponentProps,
|
|
|
|
|
ScheduleItem,
|
|
|
|
|
ZoomLevel,
|
2026-03-16 14:51:34 +09:00
|
|
|
ToolbarAction,
|
2026-02-02 10:46:01 +09:00
|
|
|
} from "./types";
|
|
|
|
|
import { useTimelineData } from "./hooks/useTimelineData";
|
2026-03-16 14:00:07 +09:00
|
|
|
import { TimelineHeader, ResourceRow, TimelineLegend, ItemTimelineCard, groupSchedulesByItem, SchedulePreviewDialog } from "./components";
|
|
|
|
|
import { zoomLevelOptions, defaultTimelineSchedulerConfig, statusOptions } from "./config";
|
2026-03-16 10:40:10 +09:00
|
|
|
import { detectConflicts, addDaysToDateString } from "./utils/conflictDetection";
|
2026-03-16 14:00:07 +09:00
|
|
|
import { v2EventBus, V2_EVENTS } from "@/lib/v2-core";
|
|
|
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
|
|
|
|
|
|
// 가상 스크롤 활성화 임계값 (리소스 수)
|
|
|
|
|
const VIRTUAL_THRESHOLD = 30;
|
2026-02-02 10:46:01 +09:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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);
|
|
|
|
|
|
2026-03-16 14:51:34 +09:00
|
|
|
// ────────── 툴바 액션 다이얼로그 상태 (통합) ──────────
|
|
|
|
|
const [actionDialog, setActionDialog] = useState<{
|
|
|
|
|
actionId: string;
|
|
|
|
|
action: ToolbarAction;
|
|
|
|
|
isLoading: boolean;
|
|
|
|
|
isApplying: boolean;
|
|
|
|
|
summary: any;
|
|
|
|
|
previews: any[];
|
|
|
|
|
deletedSchedules: any[];
|
|
|
|
|
keptSchedules: any[];
|
|
|
|
|
preparedPayload: any;
|
|
|
|
|
} | null>(null);
|
2026-03-16 14:00:07 +09:00
|
|
|
const linkedFilterValuesRef = useRef<any[]>([]);
|
|
|
|
|
|
2026-03-16 14:51:34 +09:00
|
|
|
// ────────── 아이콘 맵 ──────────
|
|
|
|
|
const TOOLBAR_ICONS: Record<string, React.ComponentType<{ className?: string }>> = useMemo(() => ({
|
|
|
|
|
Zap, Package, Plus, Download, Upload, RefreshCw, Play, FileText, Send, Sparkles, Wand2,
|
|
|
|
|
}), []);
|
2026-03-16 14:00:07 +09:00
|
|
|
|
|
|
|
|
// ────────── linkedFilter 상태 ──────────
|
|
|
|
|
const linkedFilter = config.linkedFilter;
|
|
|
|
|
const hasLinkedFilter = !!linkedFilter;
|
|
|
|
|
const [linkedFilterValues, setLinkedFilterValues] = useState<string[]>([]);
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-02-02 10:46:01 +09:00
|
|
|
// 타임라인 데이터 훅
|
|
|
|
|
const {
|
2026-03-16 14:00:07 +09:00
|
|
|
schedules: rawSchedules,
|
2026-02-02 10:46:01 +09:00
|
|
|
resources,
|
|
|
|
|
isLoading: hookLoading,
|
|
|
|
|
error: hookError,
|
|
|
|
|
zoomLevel,
|
|
|
|
|
setZoomLevel,
|
|
|
|
|
viewStartDate,
|
|
|
|
|
viewEndDate,
|
|
|
|
|
goToPrevious,
|
|
|
|
|
goToNext,
|
|
|
|
|
goToToday,
|
|
|
|
|
updateSchedule,
|
2026-03-16 14:00:07 +09:00
|
|
|
refresh: refreshTimeline,
|
2026-02-02 10:46:01 +09:00
|
|
|
} = useTimelineData(config, externalSchedules, externalResources);
|
|
|
|
|
|
2026-03-16 14:00:07 +09:00
|
|
|
// 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]);
|
|
|
|
|
|
2026-02-02 10:46:01 +09:00
|
|
|
const isLoading = externalLoading ?? hookLoading;
|
|
|
|
|
const error = externalError ?? hookError;
|
|
|
|
|
|
|
|
|
|
// 설정값
|
2026-03-16 10:40:10 +09:00
|
|
|
const rowHeight =
|
|
|
|
|
config.rowHeight || defaultTimelineSchedulerConfig.rowHeight!;
|
|
|
|
|
const headerHeight =
|
|
|
|
|
config.headerHeight || defaultTimelineSchedulerConfig.headerHeight!;
|
2026-02-02 10:46:01 +09:00
|
|
|
const resourceColumnWidth =
|
2026-03-16 10:40:10 +09:00
|
|
|
config.resourceColumnWidth ||
|
|
|
|
|
defaultTimelineSchedulerConfig.resourceColumnWidth!;
|
|
|
|
|
const cellWidthConfig =
|
|
|
|
|
config.cellWidth || defaultTimelineSchedulerConfig.cellWidth!;
|
2026-02-02 10:46:01 +09:00
|
|
|
const cellWidth = cellWidthConfig[zoomLevel] || 60;
|
|
|
|
|
|
2026-03-16 10:40:10 +09:00
|
|
|
// 리소스 자동 생성 (리소스 테이블 미설정 시 스케줄 데이터에서 추출)
|
2026-02-02 13:41:11 +09:00
|
|
|
const effectiveResources = useMemo(() => {
|
2026-03-16 10:40:10 +09:00
|
|
|
if (resources.length > 0) return resources;
|
2026-02-02 13:41:11 +09:00
|
|
|
|
|
|
|
|
const uniqueResourceIds = new Set<string>();
|
2026-03-16 10:40:10 +09:00
|
|
|
schedules.forEach((s) => {
|
|
|
|
|
if (s.resourceId) uniqueResourceIds.add(s.resourceId);
|
2026-02-02 13:41:11 +09:00
|
|
|
});
|
|
|
|
|
|
2026-03-16 10:40:10 +09:00
|
|
|
return Array.from(uniqueResourceIds).map((id) => ({ id, name: id }));
|
2026-02-02 13:41:11 +09:00
|
|
|
}, [resources, schedules]);
|
|
|
|
|
|
2026-02-02 10:46:01 +09:00
|
|
|
// 리소스별 스케줄 그룹화
|
|
|
|
|
const schedulesByResource = useMemo(() => {
|
|
|
|
|
const grouped = new Map<string, ScheduleItem[]>();
|
|
|
|
|
|
2026-03-16 10:40:10 +09:00
|
|
|
effectiveResources.forEach((r) => grouped.set(r.id, []));
|
2026-02-02 10:46:01 +09:00
|
|
|
|
|
|
|
|
schedules.forEach((schedule) => {
|
|
|
|
|
const list = grouped.get(schedule.resourceId);
|
|
|
|
|
if (list) {
|
|
|
|
|
list.push(schedule);
|
|
|
|
|
} else {
|
2026-02-02 13:41:11 +09:00
|
|
|
const firstResource = effectiveResources[0];
|
2026-02-02 10:46:01 +09:00
|
|
|
if (firstResource) {
|
2026-03-16 10:40:10 +09:00
|
|
|
grouped.get(firstResource.id)?.push(schedule);
|
2026-02-02 10:46:01 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return grouped;
|
2026-02-02 13:41:11 +09:00
|
|
|
}, [schedules, effectiveResources]);
|
2026-02-02 10:46:01 +09:00
|
|
|
|
2026-03-16 10:40:10 +09:00
|
|
|
// ────────── 충돌 감지 ──────────
|
|
|
|
|
const conflictIds = useMemo(() => {
|
|
|
|
|
if (config.showConflicts === false) return new Set<string>();
|
|
|
|
|
return detectConflicts(schedules);
|
|
|
|
|
}, [schedules, config.showConflicts]);
|
|
|
|
|
|
|
|
|
|
// ────────── 줌 레벨 변경 ──────────
|
2026-02-02 10:46:01 +09:00
|
|
|
const handleZoomIn = useCallback(() => {
|
|
|
|
|
const levels: ZoomLevel[] = ["month", "week", "day"];
|
2026-03-16 10:40:10 +09:00
|
|
|
const idx = levels.indexOf(zoomLevel);
|
|
|
|
|
if (idx < levels.length - 1) setZoomLevel(levels[idx + 1]);
|
2026-02-02 10:46:01 +09:00
|
|
|
}, [zoomLevel, setZoomLevel]);
|
|
|
|
|
|
|
|
|
|
const handleZoomOut = useCallback(() => {
|
|
|
|
|
const levels: ZoomLevel[] = ["month", "week", "day"];
|
2026-03-16 10:40:10 +09:00
|
|
|
const idx = levels.indexOf(zoomLevel);
|
|
|
|
|
if (idx > 0) setZoomLevel(levels[idx - 1]);
|
2026-02-02 10:46:01 +09:00
|
|
|
}, [zoomLevel, setZoomLevel]);
|
|
|
|
|
|
2026-03-16 10:40:10 +09:00
|
|
|
// ────────── 스케줄 클릭 ──────────
|
2026-02-02 10:46:01 +09:00
|
|
|
const handleScheduleClick = useCallback(
|
|
|
|
|
(schedule: ScheduleItem) => {
|
2026-03-16 10:40:10 +09:00
|
|
|
const resource = effectiveResources.find(
|
|
|
|
|
(r) => r.id === schedule.resourceId
|
|
|
|
|
);
|
2026-02-02 10:46:01 +09:00
|
|
|
if (resource && onScheduleClick) {
|
|
|
|
|
onScheduleClick({ schedule, resource });
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-02-02 13:41:11 +09:00
|
|
|
[effectiveResources, onScheduleClick]
|
2026-02-02 10:46:01 +09:00
|
|
|
);
|
|
|
|
|
|
2026-03-16 10:40:10 +09:00
|
|
|
// ────────── 빈 셀 클릭 ──────────
|
2026-02-02 10:46:01 +09:00
|
|
|
const handleCellClick = useCallback(
|
|
|
|
|
(resourceId: string, date: Date) => {
|
|
|
|
|
if (onCellClick) {
|
|
|
|
|
onCellClick({
|
|
|
|
|
resourceId,
|
|
|
|
|
date: date.toISOString().split("T")[0],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[onCellClick]
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-16 10:40:10 +09:00
|
|
|
// ────────── 드래그 완료 (핵심 로직) ──────────
|
|
|
|
|
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 || "잠시 후 다시 시도해주세요",
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-02 10:46:01 +09:00
|
|
|
},
|
2026-03-16 10:40:10 +09:00
|
|
|
[cellWidth, zoomLevel, updateSchedule, onDragEnd]
|
2026-02-02 10:46:01 +09:00
|
|
|
);
|
|
|
|
|
|
2026-03-16 10:40:10 +09:00
|
|
|
// ────────── 리사이즈 완료 (핵심 로직) ──────────
|
|
|
|
|
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 || "잠시 후 다시 시도해주세요",
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-02 10:46:01 +09:00
|
|
|
},
|
2026-03-16 10:40:10 +09:00
|
|
|
[cellWidth, zoomLevel, updateSchedule, onResizeEnd]
|
2026-02-02 10:46:01 +09:00
|
|
|
);
|
|
|
|
|
|
2026-03-16 10:40:10 +09:00
|
|
|
// ────────── 추가 버튼 클릭 ──────────
|
2026-02-02 10:46:01 +09:00
|
|
|
const handleAddClick = useCallback(() => {
|
2026-02-02 13:41:11 +09:00
|
|
|
if (onAddSchedule && effectiveResources.length > 0) {
|
2026-02-02 10:46:01 +09:00
|
|
|
onAddSchedule(
|
2026-02-02 13:41:11 +09:00
|
|
|
effectiveResources[0].id,
|
2026-02-02 10:46:01 +09:00
|
|
|
new Date().toISOString().split("T")[0]
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-02-02 13:41:11 +09:00
|
|
|
}, [onAddSchedule, effectiveResources]);
|
2026-02-02 10:46:01 +09:00
|
|
|
|
2026-03-16 14:51:34 +09:00
|
|
|
// ────────── 유효 툴바 액션 (config 기반 또는 하위호환 자동생성) ──────────
|
|
|
|
|
const effectiveToolbarActions: ToolbarAction[] = useMemo(() => {
|
|
|
|
|
if (config.toolbarActions && config.toolbarActions.length > 0) {
|
|
|
|
|
return config.toolbarActions;
|
2026-03-16 14:00:07 +09:00
|
|
|
}
|
2026-03-16 14:51:34 +09:00
|
|
|
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;
|
|
|
|
|
}
|
2026-03-16 14:00:07 +09:00
|
|
|
|
2026-03-16 14:51:34 +09:00
|
|
|
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<string, any[]>();
|
|
|
|
|
selectedRows.forEach((row: any) => {
|
|
|
|
|
const key = row[groupField] || "";
|
|
|
|
|
if (!key) return;
|
|
|
|
|
if (!grouped.has(key)) grouped.set(key, []);
|
|
|
|
|
grouped.get(key)!.push(row);
|
2026-03-16 14:00:07 +09:00
|
|
|
});
|
|
|
|
|
|
2026-03-16 14:51:34 +09:00
|
|
|
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;
|
2026-03-16 14:00:07 +09:00
|
|
|
}
|
|
|
|
|
|
2026-03-16 14:51:34 +09:00
|
|
|
payload = {
|
2026-03-16 14:00:07 +09:00
|
|
|
items,
|
|
|
|
|
options: {
|
2026-03-16 14:51:34 +09:00
|
|
|
...(config.staticFilters || {}),
|
|
|
|
|
...(action.payloadConfig?.extraOptions || {}),
|
2026-03-16 14:00:07 +09:00
|
|
|
},
|
2026-03-16 14:51:34 +09:00
|
|
|
};
|
|
|
|
|
} 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;
|
2026-03-16 14:00:07 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 14:51:34 +09:00
|
|
|
if (targetSchedules.length === 0) {
|
|
|
|
|
toast.warning("대상 스케줄이 없습니다");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-16 14:00:07 +09:00
|
|
|
|
2026-03-16 14:51:34 +09:00
|
|
|
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 || {},
|
|
|
|
|
};
|
2026-03-16 14:00:07 +09:00
|
|
|
}
|
|
|
|
|
|
2026-03-16 14:51:34 +09:00
|
|
|
setActionDialog({
|
|
|
|
|
actionId: action.id,
|
|
|
|
|
action,
|
|
|
|
|
isLoading: true,
|
|
|
|
|
isApplying: false,
|
|
|
|
|
summary: null,
|
|
|
|
|
previews: [],
|
|
|
|
|
deletedSchedules: [],
|
|
|
|
|
keptSchedules: [],
|
|
|
|
|
preparedPayload: payload,
|
|
|
|
|
});
|
2026-03-16 14:00:07 +09:00
|
|
|
|
|
|
|
|
try {
|
2026-03-16 14:51:34 +09:00
|
|
|
const response = await apiClient.post(action.previewApi, payload);
|
2026-03-16 14:00:07 +09:00
|
|
|
if (response.data?.success) {
|
2026-03-16 14:51:34 +09:00
|
|
|
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);
|
2026-03-16 14:00:07 +09:00
|
|
|
} else {
|
2026-03-16 14:51:34 +09:00
|
|
|
toast.error("미리보기 생성 실패", { description: response.data?.message });
|
|
|
|
|
setActionDialog(null);
|
2026-03-16 14:00:07 +09:00
|
|
|
}
|
|
|
|
|
} catch (err: any) {
|
2026-03-16 14:51:34 +09:00
|
|
|
toast.error("미리보기 요청 실패", { description: err.message });
|
|
|
|
|
setActionDialog(null);
|
2026-03-16 14:00:07 +09:00
|
|
|
}
|
2026-03-16 14:51:34 +09:00
|
|
|
}, [config.linkedFilter, config.staticFilters, config.sourceConfig, schedules]);
|
2026-03-16 14:00:07 +09:00
|
|
|
|
2026-03-16 14:51:34 +09:00
|
|
|
// ────────── 범용 액션: 확인 및 적용 ──────────
|
|
|
|
|
const handleActionApply = useCallback(async () => {
|
|
|
|
|
if (!actionDialog) return;
|
|
|
|
|
const { action, preparedPayload } = actionDialog;
|
2026-03-16 14:00:07 +09:00
|
|
|
|
2026-03-16 14:51:34 +09:00
|
|
|
setActionDialog((prev) => prev ? { ...prev, isApplying: true } : null);
|
2026-03-16 14:00:07 +09:00
|
|
|
|
|
|
|
|
try {
|
2026-03-16 14:51:34 +09:00
|
|
|
const response = await apiClient.post(action.applyApi, preparedPayload);
|
2026-03-16 14:00:07 +09:00
|
|
|
if (response.data?.success) {
|
|
|
|
|
const data = response.data.data;
|
2026-03-16 14:51:34 +09:00
|
|
|
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}건` : ""}`,
|
2026-03-16 14:00:07 +09:00
|
|
|
});
|
2026-03-16 14:51:34 +09:00
|
|
|
setActionDialog(null);
|
2026-03-16 14:00:07 +09:00
|
|
|
refreshTimeline();
|
|
|
|
|
} else {
|
2026-03-16 14:51:34 +09:00
|
|
|
toast.error("실행 실패", { description: response.data?.message });
|
2026-03-16 14:00:07 +09:00
|
|
|
}
|
|
|
|
|
} catch (err: any) {
|
2026-03-16 14:51:34 +09:00
|
|
|
toast.error("실행 실패", { description: err.message });
|
2026-03-16 14:00:07 +09:00
|
|
|
} finally {
|
2026-03-16 14:51:34 +09:00
|
|
|
setActionDialog((prev) => prev ? { ...prev, isApplying: false } : null);
|
2026-03-16 14:00:07 +09:00
|
|
|
}
|
2026-03-16 14:51:34 +09:00
|
|
|
}, [actionDialog, refreshTimeline]);
|
2026-03-16 14:00:07 +09:00
|
|
|
|
2026-03-16 10:40:10 +09:00
|
|
|
// ────────── 하단 영역 높이 계산 (툴바 + 범례) ──────────
|
|
|
|
|
const showToolbar = config.showToolbar !== false;
|
|
|
|
|
const showLegend = config.showLegend !== false;
|
2026-03-16 14:00:07 +09:00
|
|
|
|
|
|
|
|
// ────────── 가상 스크롤 ──────────
|
|
|
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const useVirtual = effectiveResources.length >= VIRTUAL_THRESHOLD;
|
|
|
|
|
|
|
|
|
|
const virtualizer = useVirtualizer({
|
|
|
|
|
count: effectiveResources.length,
|
|
|
|
|
getScrollElement: () => scrollContainerRef.current,
|
|
|
|
|
estimateSize: () => rowHeight,
|
|
|
|
|
overscan: 5,
|
|
|
|
|
});
|
2026-03-16 10:40:10 +09:00
|
|
|
|
|
|
|
|
// ────────── 디자인 모드 플레이스홀더 ──────────
|
2026-02-02 10:46:01 +09:00
|
|
|
if (isDesignMode) {
|
|
|
|
|
return (
|
2026-03-16 09:35:23 +09:00
|
|
|
<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">
|
2026-02-02 10:46:01 +09:00
|
|
|
<div className="text-center text-muted-foreground">
|
2026-03-16 09:35:23 +09:00
|
|
|
<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">
|
2026-02-02 10:46:01 +09:00
|
|
|
{config.selectedTable
|
|
|
|
|
? `테이블: ${config.selectedTable}`
|
|
|
|
|
: "테이블을 선택하세요"}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 10:40:10 +09:00
|
|
|
// ────────── 로딩 상태 ──────────
|
2026-02-02 10:46:01 +09:00
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
2026-03-16 09:35:23 +09:00
|
|
|
className="flex w-full items-center justify-center rounded-lg bg-muted/10"
|
2026-02-02 10:46:01 +09:00
|
|
|
style={{ height: config.height || 500 }}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-2 text-muted-foreground">
|
2026-03-16 09:35:23 +09:00
|
|
|
<Loader2 className="h-4 w-4 animate-spin sm:h-5 sm:w-5" />
|
|
|
|
|
<span className="text-xs sm:text-sm">로딩 중...</span>
|
2026-02-02 10:46:01 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 10:40:10 +09:00
|
|
|
// ────────── 에러 상태 ──────────
|
2026-02-02 10:46:01 +09:00
|
|
|
if (error) {
|
|
|
|
|
return (
|
|
|
|
|
<div
|
2026-03-16 09:35:23 +09:00
|
|
|
className="flex w-full items-center justify-center rounded-lg bg-destructive/10"
|
2026-02-02 10:46:01 +09:00
|
|
|
style={{ height: config.height || 500 }}
|
|
|
|
|
>
|
|
|
|
|
<div className="text-center text-destructive">
|
2026-03-16 09:35:23 +09:00
|
|
|
<p className="text-xs font-medium sm:text-sm">오류 발생</p>
|
|
|
|
|
<p className="mt-1 text-[10px] sm:text-xs">{error}</p>
|
2026-02-02 10:46:01 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 14:00:07 +09:00
|
|
|
// ────────── linkedFilter 빈 상태 (itemGrouped가 아닌 경우만 early return) ──────────
|
|
|
|
|
// itemGrouped 모드에서는 툴바를 항상 보여주기 위해 여기서 return하지 않음
|
|
|
|
|
if (config.viewMode !== "itemGrouped") {
|
|
|
|
|
if (hasLinkedFilter && !hasReceivedSelection) {
|
|
|
|
|
const emptyMsg = linkedFilter?.emptyMessage || "좌측 목록에서 품목 또는 수주를 선택하세요";
|
|
|
|
|
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">
|
|
|
|
|
<Package className="mx-auto mb-2 h-8 w-8 opacity-30 sm:mb-3 sm:h-10 sm:w-10" />
|
|
|
|
|
<p className="text-xs font-medium sm:text-sm">{emptyMsg}</p>
|
|
|
|
|
<p className="mt-1.5 max-w-[220px] text-[10px] sm:mt-2 sm:text-xs">
|
|
|
|
|
선택한 항목에 대한 생산계획 타임라인이 여기에 표시됩니다
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hasLinkedFilter && hasReceivedSelection && 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-30 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-[220px] text-[10px] sm:mt-2 sm:text-xs">
|
|
|
|
|
다른 품목을 선택하거나 스케줄을 생성해 주세요
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ────────── 데이터 없음 (linkedFilter 없고 itemGrouped가 아닌 경우) ──────────
|
|
|
|
|
if (schedules.length === 0 && config.viewMode !== "itemGrouped") {
|
2026-02-02 10:46:01 +09:00
|
|
|
return (
|
|
|
|
|
<div
|
2026-03-16 09:35:23 +09:00
|
|
|
className="flex w-full items-center justify-center rounded-lg border bg-muted/10"
|
2026-02-02 10:46:01 +09:00
|
|
|
style={{ height: config.height || 500 }}
|
|
|
|
|
>
|
|
|
|
|
<div className="text-center text-muted-foreground">
|
2026-03-16 09:35:23 +09:00
|
|
|
<Calendar className="mx-auto mb-2 h-8 w-8 opacity-50 sm:mb-3 sm:h-10 sm:w-10" />
|
2026-03-16 10:40:10 +09:00
|
|
|
<p className="text-xs font-medium sm:text-sm">
|
|
|
|
|
스케줄 데이터가 없습니다
|
|
|
|
|
</p>
|
2026-03-16 09:35:23 +09:00
|
|
|
<p className="mt-1.5 max-w-[200px] text-[10px] sm:mt-2 sm:text-xs">
|
2026-03-16 10:40:10 +09:00
|
|
|
좌측 테이블에서 품목을 선택하거나,
|
|
|
|
|
<br />
|
2026-02-03 09:34:25 +09:00
|
|
|
스케줄 생성 버튼을 눌러 스케줄을 생성하세요
|
|
|
|
|
</p>
|
2026-02-02 10:46:01 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-16 14:00:07 +09:00
|
|
|
// ────────── 품목 그룹 모드 렌더링 ──────────
|
|
|
|
|
if (config.viewMode === "itemGrouped") {
|
|
|
|
|
const itemGroups = groupSchedulesByItem(schedules);
|
|
|
|
|
|
|
|
|
|
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 flex-wrap items-center gap-1.5 border-b bg-muted/30 px-2 py-1.5 sm:gap-2 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-2 text-[10px] sm:h-7 sm:px-3 sm:text-xs">
|
|
|
|
|
오늘
|
|
|
|
|
</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-xs">
|
|
|
|
|
{viewStartDate.toLocaleDateString("ko-KR")} ~ {viewEndDate.toLocaleDateString("ko-KR")}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 줌 + 액션 버튼 */}
|
|
|
|
|
<div className="ml-auto flex items-center gap-1 sm:gap-1.5">
|
|
|
|
|
{config.showZoomControls !== false && (
|
|
|
|
|
<>
|
|
|
|
|
<Button variant="ghost" size="sm" onClick={handleZoomOut} 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="text-[10px] font-medium sm:text-xs">
|
|
|
|
|
{zoomLevelOptions.find((o) => o.value === zoomLevel)?.label}
|
|
|
|
|
</span>
|
|
|
|
|
<Button variant="ghost" size="sm" onClick={handleZoomIn} 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 className="mx-0.5 h-4 w-px bg-border" />
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<Button variant="outline" size="sm" onClick={refreshTimeline} className="h-6 gap-1 px-2 text-[10px] sm:h-7 sm:px-3 sm:text-xs">
|
|
|
|
|
<RefreshCw className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
|
|
|
|
새로고침
|
|
|
|
|
</Button>
|
2026-03-16 14:51:34 +09:00
|
|
|
{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 (
|
|
|
|
|
<Button
|
|
|
|
|
key={action.id}
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleActionPreview(action)}
|
|
|
|
|
className={cn("h-6 gap-1 px-2 text-[10px] sm:h-7 sm:px-3 sm:text-xs", action.color || "bg-primary hover:bg-primary/90")}
|
|
|
|
|
>
|
|
|
|
|
<IconComp className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
|
|
|
|
{action.label}
|
2026-03-16 14:00:07 +09:00
|
|
|
</Button>
|
2026-03-16 14:51:34 +09:00
|
|
|
);
|
|
|
|
|
})}
|
2026-03-16 14:00:07 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 범례 */}
|
|
|
|
|
{showLegend && (
|
|
|
|
|
<div className="flex shrink-0 flex-wrap items-center gap-3 border-b px-3 py-1.5 sm:gap-4 sm:px-4 sm:py-2">
|
|
|
|
|
<span className="text-[10px] text-muted-foreground sm:text-xs">생산 상태:</span>
|
|
|
|
|
{statusOptions.map((s) => (
|
|
|
|
|
<div key={s.value} className="flex items-center gap-1">
|
|
|
|
|
<div className="h-2.5 w-2.5 rounded-sm sm:h-3 sm:w-3" style={{ backgroundColor: s.color }} />
|
|
|
|
|
<span className="text-[10px] sm:text-xs">{s.label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
<span className="text-[10px] text-muted-foreground sm:text-xs">납기:</span>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<div className="h-2.5 w-2.5 rounded-sm border-2 border-destructive sm:h-3 sm:w-3" />
|
|
|
|
|
<span className="text-[10px] sm:text-xs">납기일</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 품목별 카드 목록 또는 빈 상태 */}
|
|
|
|
|
{itemGroups.length > 0 ? (
|
|
|
|
|
<div className="flex-1 space-y-3 overflow-y-auto p-3 sm:space-y-4 sm:p-4">
|
|
|
|
|
{itemGroups.map((group) => (
|
|
|
|
|
<ItemTimelineCard
|
|
|
|
|
key={group.itemCode}
|
|
|
|
|
group={group}
|
|
|
|
|
viewStartDate={viewStartDate}
|
|
|
|
|
viewEndDate={viewEndDate}
|
|
|
|
|
zoomLevel={zoomLevel}
|
|
|
|
|
cellWidth={cellWidth}
|
|
|
|
|
config={config}
|
|
|
|
|
onScheduleClick={handleScheduleClick}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex flex-1 items-center justify-center">
|
|
|
|
|
<div className="text-center text-muted-foreground">
|
|
|
|
|
{hasLinkedFilter && !hasReceivedSelection ? (
|
|
|
|
|
<>
|
|
|
|
|
<Package className="mx-auto mb-2 h-8 w-8 opacity-30 sm:mb-3 sm:h-10 sm:w-10" />
|
|
|
|
|
<p className="text-xs font-medium sm:text-sm">
|
|
|
|
|
{linkedFilter?.emptyMessage || "좌측 목록에서 품목을 선택하세요"}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="mt-1.5 max-w-[220px] text-[10px] sm:mt-2 sm:text-xs">
|
|
|
|
|
선택한 항목에 대한 생산계획 타임라인이 여기에 표시됩니다
|
|
|
|
|
</p>
|
|
|
|
|
</>
|
|
|
|
|
) : hasLinkedFilter && hasReceivedSelection ? (
|
|
|
|
|
<>
|
|
|
|
|
<Calendar className="mx-auto mb-2 h-8 w-8 opacity-30 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-[260px] text-[10px] sm:mt-2 sm:text-xs">
|
|
|
|
|
위 "자동 스케줄 생성" 버튼으로 생산계획을 생성하세요
|
|
|
|
|
</p>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<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>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-16 14:51:34 +09:00
|
|
|
{/* 범용 액션 미리보기 다이얼로그 */}
|
|
|
|
|
{actionDialog && (
|
|
|
|
|
<SchedulePreviewDialog
|
|
|
|
|
open={true}
|
|
|
|
|
onOpenChange={(open) => { 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}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-03-16 14:00:07 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ────────── 메인 렌더링 (리소스 기반) ──────────
|
2026-02-02 10:46:01 +09:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
ref={containerRef}
|
2026-03-16 10:40:10 +09:00
|
|
|
className="flex w-full flex-col overflow-hidden rounded-lg border bg-background"
|
2026-02-02 10:46:01 +09:00
|
|
|
style={{
|
|
|
|
|
height: config.height || 500,
|
|
|
|
|
maxHeight: config.maxHeight,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{/* 툴바 */}
|
2026-03-16 10:40:10 +09:00
|
|
|
{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">
|
2026-02-02 10:46:01 +09:00
|
|
|
{/* 네비게이션 */}
|
2026-03-16 09:35:23 +09:00
|
|
|
<div className="flex items-center gap-0.5 sm:gap-1">
|
2026-02-02 10:46:01 +09:00
|
|
|
{config.showNavigation !== false && (
|
|
|
|
|
<>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={goToPrevious}
|
2026-03-16 09:35:23 +09:00
|
|
|
className="h-6 px-1.5 sm:h-7 sm:px-2"
|
2026-02-02 10:46:01 +09:00
|
|
|
>
|
2026-03-16 09:35:23 +09:00
|
|
|
<ChevronLeft className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
2026-02-02 10:46:01 +09:00
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={goToToday}
|
2026-03-16 09:35:23 +09:00
|
|
|
className="h-6 px-1.5 text-xs sm:h-7 sm:px-2 sm:text-sm"
|
2026-02-02 10:46:01 +09:00
|
|
|
>
|
|
|
|
|
오늘
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={goToNext}
|
2026-03-16 09:35:23 +09:00
|
|
|
className="h-6 px-1.5 sm:h-7 sm:px-2"
|
2026-02-02 10:46:01 +09:00
|
|
|
>
|
2026-03-16 09:35:23 +09:00
|
|
|
<ChevronRight className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
2026-02-02 10:46:01 +09:00
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-16 10:40:10 +09:00
|
|
|
{/* 날짜 범위 표시 */}
|
2026-03-16 09:35:23 +09:00
|
|
|
<span className="ml-1 text-[10px] text-muted-foreground sm:ml-2 sm:text-sm">
|
2026-02-02 10:46:01 +09:00
|
|
|
{viewStartDate.getFullYear()}년 {viewStartDate.getMonth() + 1}월{" "}
|
2026-03-16 10:40:10 +09:00
|
|
|
{viewStartDate.getDate()}일 ~ {viewEndDate.getMonth() + 1}월{" "}
|
|
|
|
|
{viewEndDate.getDate()}일
|
2026-02-02 10:46:01 +09:00
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 오른쪽 컨트롤 */}
|
2026-03-16 09:35:23 +09:00
|
|
|
<div className="flex items-center gap-1 sm:gap-2">
|
2026-03-16 10:40:10 +09:00
|
|
|
{/* 충돌 카운트 표시 */}
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-02 10:46:01 +09:00
|
|
|
{/* 줌 컨트롤 */}
|
|
|
|
|
{config.showZoomControls !== false && (
|
2026-03-16 09:35:23 +09:00
|
|
|
<div className="flex items-center gap-0.5 sm:gap-1">
|
2026-02-02 10:46:01 +09:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleZoomOut}
|
|
|
|
|
disabled={zoomLevel === "month"}
|
2026-03-16 09:35:23 +09:00
|
|
|
className="h-6 px-1.5 sm:h-7 sm:px-2"
|
2026-02-02 10:46:01 +09:00
|
|
|
>
|
2026-03-16 09:35:23 +09:00
|
|
|
<ZoomOut className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
2026-02-02 10:46:01 +09:00
|
|
|
</Button>
|
2026-03-16 09:35:23 +09:00
|
|
|
<span className="min-w-[20px] text-center text-[10px] text-muted-foreground sm:min-w-[24px] sm:text-xs">
|
2026-03-16 10:40:10 +09:00
|
|
|
{
|
|
|
|
|
zoomLevelOptions.find((o) => o.value === zoomLevel)
|
|
|
|
|
?.label
|
|
|
|
|
}
|
2026-02-02 10:46:01 +09:00
|
|
|
</span>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleZoomIn}
|
|
|
|
|
disabled={zoomLevel === "day"}
|
2026-03-16 09:35:23 +09:00
|
|
|
className="h-6 px-1.5 sm:h-7 sm:px-2"
|
2026-02-02 10:46:01 +09:00
|
|
|
>
|
2026-03-16 09:35:23 +09:00
|
|
|
<ZoomIn className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
2026-02-02 10:46:01 +09:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 추가 버튼 */}
|
|
|
|
|
{config.showAddButton !== false && config.editable && (
|
|
|
|
|
<Button
|
|
|
|
|
variant="default"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleAddClick}
|
2026-03-16 09:35:23 +09:00
|
|
|
className="h-6 text-xs sm:h-7 sm:text-sm"
|
2026-02-02 10:46:01 +09:00
|
|
|
>
|
2026-03-16 09:35:23 +09:00
|
|
|
<Plus className="mr-0.5 h-3.5 w-3.5 sm:mr-1 sm:h-4 sm:w-4" />
|
2026-02-02 10:46:01 +09:00
|
|
|
추가
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-03-16 10:40:10 +09:00
|
|
|
{/* 타임라인 본문 (스크롤 영역) */}
|
2026-03-16 14:00:07 +09:00
|
|
|
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-auto">
|
2026-02-02 10:46:01 +09:00
|
|
|
<div className="min-w-max">
|
|
|
|
|
{/* 헤더 */}
|
|
|
|
|
<TimelineHeader
|
|
|
|
|
startDate={viewStartDate}
|
|
|
|
|
endDate={viewEndDate}
|
|
|
|
|
zoomLevel={zoomLevel}
|
|
|
|
|
cellWidth={cellWidth}
|
|
|
|
|
headerHeight={headerHeight}
|
|
|
|
|
resourceColumnWidth={resourceColumnWidth}
|
|
|
|
|
showTodayLine={config.showTodayLine}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-03-16 14:00:07 +09:00
|
|
|
{/* 리소스 행들 - 30개 이상이면 가상 스크롤 */}
|
|
|
|
|
{useVirtual ? (
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
|
|
|
position: "relative",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
|
|
|
const resource = effectiveResources[virtualRow.index];
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={resource.id}
|
|
|
|
|
style={{
|
|
|
|
|
position: "absolute",
|
|
|
|
|
top: virtualRow.start,
|
|
|
|
|
left: 0,
|
|
|
|
|
width: "100%",
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<ResourceRow
|
|
|
|
|
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>
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
2026-02-02 10:46:01 +09:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-16 10:40:10 +09:00
|
|
|
|
|
|
|
|
{/* 범례 */}
|
|
|
|
|
{showLegend && (
|
|
|
|
|
<div className="shrink-0">
|
|
|
|
|
<TimelineLegend config={config} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-02 10:46:01 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|