- Added new entries to .gitignore for multi-agent MCP task queue and related rules. - Removed "즉시 저장" (quick insert) options from the ScreenSettingModal and BasicTab components to streamline button configurations. - Cleaned up unused event options in the V2ButtonConfigPanel to enhance clarity and maintainability. These changes aim to improve project organization and simplify the user interface by eliminating redundant options.
993 lines
37 KiB
TypeScript
993 lines
37 KiB
TypeScript
"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<HTMLDivElement>(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<any[]>([]);
|
|
|
|
// ────────── 아이콘 맵 ──────────
|
|
const TOOLBAR_ICONS: Record<string, React.ComponentType<{ className?: string }>> = useMemo(() => ({
|
|
Zap, Package, Plus, Download, Upload, RefreshCw, Play, FileText, Send, Sparkles, Wand2,
|
|
}), []);
|
|
|
|
// ────────── 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]);
|
|
|
|
// 타임라인 데이터 훅
|
|
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<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.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "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,
|
|
(() => { const _n = new Date(); return `${_n.getFullYear()}-${String(_n.getMonth() + 1).padStart(2, "0")}-${String(_n.getDate()).padStart(2, "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<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);
|
|
});
|
|
|
|
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 _dn = new Date();
|
|
const earliestDate = dates[0] || `${_dn.getFullYear()}-${String(_dn.getMonth() + 1).padStart(2, "0")}-${String(_dn.getDate()).padStart(2, "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<HTMLDivElement>(null);
|
|
const useVirtual = effectiveResources.length >= VIRTUAL_THRESHOLD;
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: effectiveResources.length,
|
|
getScrollElement: () => scrollContainerRef.current,
|
|
estimateSize: () => rowHeight,
|
|
overscan: 5,
|
|
});
|
|
|
|
// ────────── 디자인 모드 플레이스홀더 ──────────
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ────────── 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") {
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ────────── 품목 그룹 모드 렌더링 ──────────
|
|
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>
|
|
{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}
|
|
</Button>
|
|
);
|
|
})}
|
|
</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>
|
|
)}
|
|
|
|
{/* 범용 액션 미리보기 다이얼로그 */}
|
|
{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}
|
|
/>
|
|
)}
|
|
</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 ref={scrollContainerRef} 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}
|
|
/>
|
|
|
|
{/* 리소스 행들 - 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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 범례 */}
|
|
{showLegend && (
|
|
<div className="shrink-0">
|
|
<TimelineLegend config={config} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|