feat: add schedule preview functionality for production plans
- Implemented previewSchedule and previewSemiSchedule functions in the production controller to allow users to preview schedule changes without making actual database modifications. - Added corresponding routes for schedule preview in productionRoutes. - Enhanced productionPlanService with logic to generate schedule previews based on provided items and plan IDs. - Introduced SchedulePreviewDialog component to display the preview results in the frontend, including summary and detailed views of planned schedules. These updates improve the user experience by providing a way to visualize scheduling changes before applying them, ensuring better planning and decision-making. Made-with: Cursor
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useMemo, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -9,18 +9,27 @@ import {
|
||||
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 } from "./components";
|
||||
import { zoomLevelOptions, defaultTimelineSchedulerConfig } from "./config";
|
||||
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 메인 컴포넌트
|
||||
@@ -44,9 +53,59 @@ export function TimelineSchedulerComponent({
|
||||
}: 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,
|
||||
schedules: rawSchedules,
|
||||
resources,
|
||||
isLoading: hookLoading,
|
||||
error: hookError,
|
||||
@@ -58,8 +117,21 @@ export function TimelineSchedulerComponent({
|
||||
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;
|
||||
|
||||
@@ -267,11 +339,212 @@ export function TimelineSchedulerComponent({
|
||||
}
|
||||
}, [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 toolbarHeight = showToolbar ? 36 : 0;
|
||||
const legendHeight = showLegend ? 28 : 0;
|
||||
|
||||
// ────────── 가상 스크롤 ──────────
|
||||
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) {
|
||||
@@ -320,8 +593,49 @@ export function TimelineSchedulerComponent({
|
||||
);
|
||||
}
|
||||
|
||||
// ────────── 데이터 없음 ──────────
|
||||
if (schedules.length === 0) {
|
||||
// ────────── 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"
|
||||
@@ -342,7 +656,178 @@ export function TimelineSchedulerComponent({
|
||||
);
|
||||
}
|
||||
|
||||
// ────────── 메인 렌더링 ──────────
|
||||
// ────────── 품목 그룹 모드 렌더링 ──────────
|
||||
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}
|
||||
@@ -450,7 +935,7 @@ export function TimelineSchedulerComponent({
|
||||
)}
|
||||
|
||||
{/* 타임라인 본문 (스크롤 영역) */}
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<div ref={scrollContainerRef} className="min-h-0 flex-1 overflow-auto">
|
||||
<div className="min-w-max">
|
||||
{/* 헤더 */}
|
||||
<TimelineHeader
|
||||
@@ -463,28 +948,73 @@ export function TimelineSchedulerComponent({
|
||||
showTodayLine={config.showTodayLine}
|
||||
/>
|
||||
|
||||
{/* 리소스 행들 */}
|
||||
<div>
|
||||
{effectiveResources.map((resource) => (
|
||||
<ResourceRow
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
schedules={schedulesByResource.get(resource.id) || []}
|
||||
startDate={viewStartDate}
|
||||
endDate={viewEndDate}
|
||||
zoomLevel={zoomLevel}
|
||||
rowHeight={rowHeight}
|
||||
cellWidth={cellWidth}
|
||||
resourceColumnWidth={resourceColumnWidth}
|
||||
config={config}
|
||||
conflictIds={conflictIds}
|
||||
onScheduleClick={handleScheduleClick}
|
||||
onCellClick={handleCellClick}
|
||||
onDragComplete={handleDragComplete}
|
||||
onResizeComplete={handleResizeComplete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* 리소스 행들 - 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user