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

1030 lines
39 KiB
TypeScript

"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ChevronLeft,
ChevronRight,
Calendar,
Plus,
Loader2,
ZoomIn,
ZoomOut,
Package,
Zap,
RefreshCw,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
TimelineSchedulerComponentProps,
ScheduleItem,
ZoomLevel,
} 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 [showPreviewDialog, setShowPreviewDialog] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [previewApplying, setPreviewApplying] = useState(false);
const [previewSummary, setPreviewSummary] = useState<any>(null);
const [previewItems, setPreviewItems] = useState<any[]>([]);
const [previewDeleted, setPreviewDeleted] = useState<any[]>([]);
const [previewKept, setPreviewKept] = useState<any[]>([]);
const linkedFilterValuesRef = useRef<any[]>([]);
// ────────── 반제품 계획 생성 상태 ──────────
const [showSemiPreviewDialog, setShowSemiPreviewDialog] = useState(false);
const [semiPreviewLoading, setSemiPreviewLoading] = useState(false);
const [semiPreviewApplying, setSemiPreviewApplying] = useState(false);
const [semiPreviewSummary, setSemiPreviewSummary] = useState<any>(null);
const [semiPreviewItems, setSemiPreviewItems] = useState<any[]>([]);
const [semiPreviewDeleted, setSemiPreviewDeleted] = useState<any[]>([]);
const [semiPreviewKept, setSemiPreviewKept] = useState<any[]>([]);
// ────────── 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.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 handleAutoSchedulePreview = useCallback(async () => {
const selectedRows = linkedFilterValuesRef.current;
if (!selectedRows || selectedRows.length === 0) {
toast.warning("좌측에서 품목을 선택해주세요");
return;
}
const sourceField = config.linkedFilter?.sourceField || "part_code";
const grouped = new Map<string, any[]>();
selectedRows.forEach((row: any) => {
const key = row[sourceField] || "";
if (!key) return;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(row);
});
const items = Array.from(grouped.entries()).map(([itemCode, rows]) => {
const totalBalanceQty = rows.reduce((sum: number, r: any) => sum + (Number(r.balance_qty) || 0), 0);
const earliestDueDate = rows
.map((r: any) => r.due_date)
.filter(Boolean)
.sort()[0] || new Date().toISOString().split("T")[0];
const first = rows[0];
return {
item_code: itemCode,
item_name: first.part_name || first.item_name || itemCode,
required_qty: totalBalanceQty,
earliest_due_date: typeof earliestDueDate === "string" ? earliestDueDate.split("T")[0] : earliestDueDate,
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;
}
setShowPreviewDialog(true);
setPreviewLoading(true);
try {
const response = await apiClient.post("/production/generate-schedule/preview", {
items,
options: {
product_type: config.staticFilters?.product_type || "완제품",
safety_lead_time: 1,
recalculate_unstarted: true,
},
});
if (response.data?.success) {
setPreviewSummary(response.data.data.summary);
setPreviewItems(response.data.data.previews);
setPreviewDeleted(response.data.data.deletedSchedules || []);
setPreviewKept(response.data.data.keptSchedules || []);
} else {
toast.error("미리보기 생성 실패");
setShowPreviewDialog(false);
}
} catch (err: any) {
toast.error("미리보기 요청 실패", { description: err.message });
setShowPreviewDialog(false);
} finally {
setPreviewLoading(false);
}
}, [config.linkedFilter, config.staticFilters]);
// ────────── 자동 스케줄 생성: 확인 및 적용 ──────────
const handleAutoScheduleApply = useCallback(async () => {
if (!previewItems || previewItems.length === 0) return;
setPreviewApplying(true);
const items = previewItems.map((p: any) => ({
item_code: p.item_code,
item_name: p.item_name,
required_qty: p.required_qty,
earliest_due_date: p.due_date,
hourly_capacity: p.hourly_capacity,
daily_capacity: p.daily_capacity,
}));
try {
const response = await apiClient.post("/production/generate-schedule", {
items,
options: {
product_type: config.staticFilters?.product_type || "완제품",
safety_lead_time: 1,
recalculate_unstarted: true,
},
});
if (response.data?.success) {
const summary = response.data.data.summary;
toast.success("생산계획 업데이트 완료", {
description: `신규: ${summary.new_count}건, 유지: ${summary.kept_count}건, 삭제: ${summary.deleted_count}`,
});
setShowPreviewDialog(false);
refreshTimeline();
} else {
toast.error("생산계획 생성 실패");
}
} catch (err: any) {
toast.error("생산계획 생성 실패", { description: err.message });
} finally {
setPreviewApplying(false);
}
}, [previewItems, config.staticFilters, refreshTimeline]);
// ────────── 반제품 계획 생성: 미리보기 요청 ──────────
const handleSemiSchedulePreview = useCallback(async () => {
// 현재 타임라인에 표시된 완제품 스케줄의 plan ID 수집
const finishedSchedules = schedules.filter((s) => {
const productType = (s.data as any)?.product_type || "";
return productType === "완제품";
});
if (finishedSchedules.length === 0) {
toast.warning("완제품 스케줄이 없습니다. 먼저 완제품 계획을 생성해주세요.");
return;
}
const planIds = finishedSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id));
if (planIds.length === 0) {
toast.warning("유효한 완제품 계획 ID가 없습니다");
return;
}
setShowSemiPreviewDialog(true);
setSemiPreviewLoading(true);
try {
const response = await apiClient.post("/production/generate-semi-schedule/preview", {
plan_ids: planIds,
options: { considerStock: true },
});
if (response.data?.success) {
setSemiPreviewSummary(response.data.data.summary);
setSemiPreviewItems(response.data.data.previews || []);
setSemiPreviewDeleted(response.data.data.deletedSchedules || []);
setSemiPreviewKept(response.data.data.keptSchedules || []);
} else {
toast.error("반제품 미리보기 실패", { description: response.data?.message });
setShowSemiPreviewDialog(false);
}
} catch (err: any) {
toast.error("반제품 미리보기 요청 실패", { description: err.message });
setShowSemiPreviewDialog(false);
} finally {
setSemiPreviewLoading(false);
}
}, [schedules]);
// ────────── 반제품 계획 생성: 확인 및 적용 ──────────
const handleSemiScheduleApply = useCallback(async () => {
const finishedSchedules = schedules.filter((s) => {
const productType = (s.data as any)?.product_type || "";
return productType === "완제품";
});
const planIds = finishedSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id));
if (planIds.length === 0) return;
setSemiPreviewApplying(true);
try {
const response = await apiClient.post("/production/generate-semi-schedule", {
plan_ids: planIds,
options: { considerStock: true },
});
if (response.data?.success) {
const data = response.data.data;
toast.success("반제품 계획 생성 완료", {
description: `${data.count}건의 반제품 계획이 생성되었습니다`,
});
setShowSemiPreviewDialog(false);
refreshTimeline();
} else {
toast.error("반제품 계획 생성 실패");
}
} catch (err: any) {
toast.error("반제품 계획 생성 실패", { description: err.message });
} finally {
setSemiPreviewApplying(false);
}
}, [schedules, 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>
{config.staticFilters?.product_type === "완제품" && (
<>
<Button size="sm" onClick={handleAutoSchedulePreview} className="h-6 gap-1 bg-emerald-600 px-2 text-[10px] hover:bg-emerald-700 sm:h-7 sm:px-3 sm:text-xs">
<Zap className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
</Button>
<Button size="sm" onClick={handleSemiSchedulePreview} className="h-6 gap-1 bg-blue-600 px-2 text-[10px] hover:bg-blue-700 sm:h-7 sm:px-3 sm:text-xs">
<Package className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
</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>
)}
{/* 완제품 스케줄 생성 미리보기 다이얼로그 */}
<SchedulePreviewDialog
open={showPreviewDialog}
onOpenChange={setShowPreviewDialog}
isLoading={previewLoading}
summary={previewSummary}
previews={previewItems}
deletedSchedules={previewDeleted}
keptSchedules={previewKept}
onConfirm={handleAutoScheduleApply}
isApplying={previewApplying}
/>
{/* 반제품 계획 생성 미리보기 다이얼로그 */}
<SchedulePreviewDialog
open={showSemiPreviewDialog}
onOpenChange={setShowSemiPreviewDialog}
isLoading={semiPreviewLoading}
summary={semiPreviewSummary}
previews={semiPreviewItems}
deletedSchedules={semiPreviewDeleted}
keptSchedules={semiPreviewKept}
onConfirm={handleSemiScheduleApply}
isApplying={semiPreviewApplying}
title="반제품 계획 자동 생성"
description="BOM 기반으로 완제품 계획에 필요한 반제품 생산계획을 생성합니다"
/>
</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>
);
}