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:
kjs
2026-03-16 14:00:07 +09:00
parent 5cdbd2446b
commit 64c9f25f63
16 changed files with 2515 additions and 97 deletions

View File

@@ -153,15 +153,37 @@ export function useGroupedData(
}
);
const responseData = response.data?.data?.data || response.data?.data || [];
setRawData(Array.isArray(responseData) ? responseData : []);
let responseData = response.data?.data?.data || response.data?.data || [];
responseData = Array.isArray(responseData) ? responseData : [];
// dataFilter 적용 (클라이언트 사이드 필터링)
if (config.dataFilter && config.dataFilter.length > 0) {
responseData = responseData.filter((item: any) => {
return config.dataFilter!.every((f) => {
const val = item[f.column];
switch (f.operator) {
case "eq": return val === f.value;
case "ne": return f.value === null ? (val !== null && val !== undefined && val !== "") : val !== f.value;
case "gt": return Number(val) > Number(f.value);
case "lt": return Number(val) < Number(f.value);
case "gte": return Number(val) >= Number(f.value);
case "lte": return Number(val) <= Number(f.value);
case "like": return String(val ?? "").includes(String(f.value));
case "in": return Array.isArray(f.value) ? f.value.includes(val) : false;
default: return true;
}
});
});
}
setRawData(responseData);
} catch (err: any) {
setError(err.message || "데이터 로드 중 오류 발생");
setRawData([]);
} finally {
setIsLoading(false);
}
}, [tableName, externalData, searchFilters]);
}, [tableName, externalData, searchFilters, config.dataFilter]);
// 초기 데이터 로드
useEffect(() => {

View File

@@ -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>

View File

@@ -0,0 +1,297 @@
"use client";
import React, { useMemo, useRef } from "react";
import { cn } from "@/lib/utils";
import { Flame } from "lucide-react";
import { ScheduleItem, TimelineSchedulerConfig, ZoomLevel } from "../types";
import { statusOptions, dayLabels } from "../config";
interface ItemScheduleGroup {
itemCode: string;
itemName: string;
hourlyCapacity: number;
dailyCapacity: number;
schedules: ScheduleItem[];
totalPlanQty: number;
totalCompletedQty: number;
remainingQty: number;
dueDates: { date: string; isUrgent: boolean }[];
}
interface ItemTimelineCardProps {
group: ItemScheduleGroup;
viewStartDate: Date;
viewEndDate: Date;
zoomLevel: ZoomLevel;
cellWidth: number;
config: TimelineSchedulerConfig;
onScheduleClick?: (schedule: ScheduleItem) => void;
}
const toDateString = (d: Date) => d.toISOString().split("T")[0];
const addDays = (d: Date, n: number) => {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
};
const diffDays = (a: Date, b: Date) =>
Math.round((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24));
function generateDateCells(start: Date, end: Date) {
const cells: { date: Date; label: string; dayLabel: string; isWeekend: boolean; isToday: boolean; dateStr: string }[] = [];
const today = toDateString(new Date());
let cur = new Date(start);
while (cur <= end) {
const d = new Date(cur);
const dow = d.getDay();
cells.push({
date: d,
label: String(d.getDate()),
dayLabel: dayLabels[dow],
isWeekend: dow === 0 || dow === 6,
isToday: toDateString(d) === today,
dateStr: toDateString(d),
});
cur = addDays(cur, 1);
}
return cells;
}
export function ItemTimelineCard({
group,
viewStartDate,
viewEndDate,
zoomLevel,
cellWidth,
config,
onScheduleClick,
}: ItemTimelineCardProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const dateCells = useMemo(
() => generateDateCells(viewStartDate, viewEndDate),
[viewStartDate, viewEndDate]
);
const totalWidth = dateCells.length * cellWidth;
const dueDateSet = useMemo(() => {
const set = new Set<string>();
group.dueDates.forEach((d) => set.add(d.date));
return set;
}, [group.dueDates]);
const urgentDateSet = useMemo(() => {
const set = new Set<string>();
group.dueDates.filter((d) => d.isUrgent).forEach((d) => set.add(d.date));
return set;
}, [group.dueDates]);
const statusColor = (status: string) =>
config.statusColors?.[status as keyof typeof config.statusColors] ||
statusOptions.find((s) => s.value === status)?.color ||
"#3b82f6";
const isUrgentItem = group.dueDates.some((d) => d.isUrgent);
const hasRemaining = group.remainingQty > 0;
return (
<div className="rounded-lg border bg-background">
{/* 품목 헤더 */}
<div className="flex items-start justify-between border-b px-3 py-2 sm:px-4 sm:py-3">
<div className="flex items-start gap-2">
<input type="checkbox" className="mt-1 h-3.5 w-3.5 rounded border-border sm:h-4 sm:w-4" />
<div>
<p className="text-[10px] text-muted-foreground sm:text-xs">{group.itemCode}</p>
<p className="text-xs font-semibold sm:text-sm">{group.itemName}</p>
</div>
</div>
<div className="text-right text-[10px] text-muted-foreground sm:text-xs">
<p>
: <span className="font-semibold text-foreground">{group.hourlyCapacity.toLocaleString()}</span> EA
</p>
<p>
: <span className="font-semibold text-foreground">{group.dailyCapacity.toLocaleString()}</span> EA
</p>
</div>
</div>
{/* 타임라인 영역 */}
<div ref={scrollRef} className="overflow-x-auto">
<div style={{ width: totalWidth, minWidth: "100%" }}>
{/* 날짜 헤더 */}
<div className="flex border-b">
{dateCells.map((cell) => {
const isDueDate = dueDateSet.has(cell.dateStr);
const isUrgentDate = urgentDateSet.has(cell.dateStr);
return (
<div
key={cell.dateStr}
className={cn(
"flex shrink-0 flex-col items-center justify-center border-r py-1",
cell.isWeekend && "bg-muted/30",
cell.isToday && "bg-primary/5",
isDueDate && "ring-2 ring-inset ring-destructive",
isUrgentDate && "bg-destructive/5"
)}
style={{ width: cellWidth }}
>
<span className={cn(
"text-[10px] font-medium sm:text-xs",
cell.isToday && "text-primary",
cell.isWeekend && "text-destructive/70"
)}>
{cell.label}
</span>
<span className={cn(
"text-[8px] sm:text-[10px]",
cell.isToday && "text-primary",
cell.isWeekend && "text-destructive/50",
!cell.isToday && !cell.isWeekend && "text-muted-foreground"
)}>
{cell.dayLabel}
</span>
</div>
);
})}
</div>
{/* 스케줄 바 영역 */}
<div className="relative" style={{ height: 48 }}>
{group.schedules.map((schedule) => {
const schedStart = new Date(schedule.startDate);
const schedEnd = new Date(schedule.endDate);
const startOffset = diffDays(schedStart, viewStartDate);
const endOffset = diffDays(schedEnd, viewStartDate);
const left = Math.max(0, startOffset * cellWidth);
const right = Math.min(totalWidth, (endOffset + 1) * cellWidth);
const width = Math.max(cellWidth * 0.5, right - left);
if (right < 0 || left > totalWidth) return null;
const qty = Number(schedule.data?.plan_qty) || 0;
const color = statusColor(schedule.status);
return (
<div
key={schedule.id}
className="absolute cursor-pointer rounded-md shadow-sm transition-shadow hover:shadow-md"
style={{
left,
top: 8,
width,
height: 32,
backgroundColor: color,
}}
onClick={() => onScheduleClick?.(schedule)}
title={`${schedule.title} (${schedule.startDate} ~ ${schedule.endDate})`}
>
<div className="flex h-full items-center justify-center truncate px-1 text-[10px] font-medium text-white sm:text-xs">
{qty > 0 ? `${qty.toLocaleString()} EA` : schedule.title}
</div>
</div>
);
})}
{/* 납기일 마커 */}
{group.dueDates.map((dueDate, idx) => {
const d = new Date(dueDate.date);
const offset = diffDays(d, viewStartDate);
if (offset < 0 || offset > dateCells.length) return null;
const left = offset * cellWidth + cellWidth / 2;
return (
<div
key={`due-${idx}`}
className="absolute top-0 bottom-0"
style={{ left, width: 0 }}
>
<div className={cn(
"absolute top-0 h-full w-px",
dueDate.isUrgent ? "bg-destructive" : "bg-destructive/40"
)} />
</div>
);
})}
</div>
</div>
</div>
{/* 하단 잔량 영역 */}
<div className="flex items-center gap-2 border-t px-3 py-1.5 sm:px-4 sm:py-2">
<input type="checkbox" className="h-3.5 w-3.5 rounded border-border sm:h-4 sm:w-4" />
{hasRemaining && (
<div className={cn(
"flex items-center gap-1 rounded-md px-2 py-0.5 text-[10px] font-semibold sm:text-xs",
isUrgentItem
? "bg-destructive/10 text-destructive"
: "bg-warning/10 text-warning"
)}>
{isUrgentItem && <Flame className="h-3 w-3 sm:h-3.5 sm:w-3.5" />}
{group.remainingQty.toLocaleString()} EA
</div>
)}
{/* 스크롤 인디케이터 */}
<div className="ml-auto flex-1">
<div className="h-1 w-16 rounded-full bg-muted" />
</div>
</div>
</div>
);
}
/**
* 스케줄 데이터를 품목별로 그룹화
*/
export function groupSchedulesByItem(schedules: ScheduleItem[]): ItemScheduleGroup[] {
const grouped = new Map<string, ScheduleItem[]>();
schedules.forEach((s) => {
const key = s.data?.item_code || "unknown";
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(s);
});
const result: ItemScheduleGroup[] = [];
grouped.forEach((items, itemCode) => {
const first = items[0];
const hourlyCapacity = Number(first.data?.hourly_capacity) || 0;
const dailyCapacity = Number(first.data?.daily_capacity) || 0;
const totalPlanQty = items.reduce((sum, s) => sum + (Number(s.data?.plan_qty) || 0), 0);
const totalCompletedQty = items.reduce((sum, s) => sum + (Number(s.data?.completed_qty) || 0), 0);
const dueDates: { date: string; isUrgent: boolean }[] = [];
const seenDueDates = new Set<string>();
items.forEach((s) => {
const dd = s.data?.due_date;
if (dd) {
const dateStr = typeof dd === "string" ? dd.split("T")[0] : "";
if (dateStr && !seenDueDates.has(dateStr)) {
seenDueDates.add(dateStr);
const isUrgent = s.data?.priority === "urgent" || s.data?.priority === "high";
dueDates.push({ date: dateStr, isUrgent });
}
}
});
result.push({
itemCode,
itemName: first.data?.item_name || first.title || itemCode,
hourlyCapacity,
dailyCapacity,
schedules: items.sort(
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
),
totalPlanQty,
totalCompletedQty,
remainingQty: totalPlanQty - totalCompletedQty,
dueDates: dueDates.sort((a, b) => a.date.localeCompare(b.date)),
});
});
return result.sort((a, b) => a.itemCode.localeCompare(b.itemCode));
}

View File

@@ -0,0 +1,282 @@
"use client";
import React from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2, AlertTriangle, Check, X, Trash2, Play } from "lucide-react";
import { cn } from "@/lib/utils";
import { statusOptions } from "../config";
interface PreviewItem {
item_code: string;
item_name: string;
required_qty: number;
daily_capacity: number;
hourly_capacity: number;
production_days: number;
start_date: string;
end_date: string;
due_date: string;
order_count: number;
status: string;
}
interface ExistingSchedule {
id: string;
plan_no: string;
item_code: string;
item_name: string;
plan_qty: string;
start_date: string;
end_date: string;
status: string;
completed_qty?: string;
}
interface PreviewSummary {
total: number;
new_count: number;
kept_count: number;
deleted_count: number;
}
interface SchedulePreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
isLoading: boolean;
summary: PreviewSummary | null;
previews: PreviewItem[];
deletedSchedules: ExistingSchedule[];
keptSchedules: ExistingSchedule[];
onConfirm: () => void;
isApplying: boolean;
title?: string;
description?: string;
}
const summaryCards = [
{ key: "total", label: "총 계획", color: "bg-primary/10 text-primary" },
{ key: "new_count", label: "신규 입력", color: "bg-emerald-50 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400" },
{ key: "deleted_count", label: "삭제될", color: "bg-destructive/10 text-destructive" },
{ key: "kept_count", label: "유지(진행중)", color: "bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-400" },
];
function formatDate(d: string | null | undefined): string {
if (!d) return "-";
const s = typeof d === "string" ? d : String(d);
return s.split("T")[0];
}
export function SchedulePreviewDialog({
open,
onOpenChange,
isLoading,
summary,
previews,
deletedSchedules,
keptSchedules,
onConfirm,
isApplying,
title,
description,
}: SchedulePreviewDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[640px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{title || "생산계획 변경사항 확인"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{description || "변경사항을 확인해주세요"}
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
<span className="ml-2 text-sm text-muted-foreground"> ...</span>
</div>
) : summary ? (
<div className="max-h-[60vh] space-y-4 overflow-y-auto">
{/* 경고 배너 */}
<div className="flex items-start gap-2 rounded-md bg-amber-50 px-3 py-2 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<div className="text-xs sm:text-sm">
<p className="font-medium"> </p>
<p className="mt-0.5 text-[10px] text-amber-700 dark:text-amber-400 sm:text-xs">
.
</p>
</div>
</div>
{/* 요약 카드 */}
<div className="grid grid-cols-4 gap-2">
{summaryCards.map((card) => (
<div
key={card.key}
className={cn("rounded-lg px-3 py-3 text-center", card.color)}
>
<p className="text-lg font-bold sm:text-xl">
{(summary as any)[card.key] ?? 0}
</p>
<p className="text-[10px] sm:text-xs">{card.label}</p>
</div>
))}
</div>
{/* 신규 생성 목록 */}
{previews.length > 0 && (
<div>
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-emerald-600 sm:text-sm">
<Check className="h-3.5 w-3.5" />
({previews.length})
</p>
<div className="space-y-2">
{previews.map((item, idx) => {
const statusInfo = statusOptions.find((s) => s.value === item.status);
return (
<div key={idx} className="rounded-md border border-emerald-200 bg-emerald-50/50 px-3 py-2 dark:border-emerald-800 dark:bg-emerald-950/30">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold sm:text-sm">
{item.item_code} - {item.item_name}
</p>
<span
className="rounded-md px-2 py-0.5 text-[10px] font-medium text-white sm:text-xs"
style={{ backgroundColor: statusInfo?.color || "#3b82f6" }}
>
{statusInfo?.label || item.status}
</span>
</div>
<p className="mt-1 text-xs text-primary sm:text-sm">
: <span className="font-semibold">{(item.required_qty || (item as any).plan_qty || 0).toLocaleString()}</span> EA
</p>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[10px] text-muted-foreground sm:text-xs">
<span>: {formatDate(item.start_date)}</span>
<span>: {formatDate(item.end_date)}</span>
</div>
{item.order_count ? (
<p className="mt-0.5 text-[10px] text-muted-foreground sm:text-xs">
{item.order_count} ( {item.required_qty.toLocaleString()} EA)
</p>
) : (item as any).parent_item_name ? (
<p className="mt-0.5 text-[10px] text-muted-foreground sm:text-xs">
: {(item as any).parent_plan_no} ({(item as any).parent_item_name}) | BOM : {(item as any).bom_qty || 1}
</p>
) : null}
</div>
);
})}
</div>
</div>
)}
{/* 삭제될 목록 */}
{deletedSchedules.length > 0 && (
<div>
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-destructive sm:text-sm">
<Trash2 className="h-3.5 w-3.5" />
({deletedSchedules.length})
</p>
<div className="space-y-2">
{deletedSchedules.map((item, idx) => (
<div key={idx} className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold sm:text-sm">
{item.item_code} - {item.item_name}
</p>
<span className="rounded-md bg-destructive/20 px-2 py-0.5 text-[10px] font-medium text-destructive sm:text-xs">
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground sm:text-sm">
{item.plan_no} | : {Number(item.plan_qty || 0).toLocaleString()} EA
</p>
<div className="mt-0.5 flex flex-wrap gap-x-4 text-[10px] text-muted-foreground sm:text-xs">
<span>: {formatDate(item.start_date)}</span>
<span>: {formatDate(item.end_date)}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* 유지될 목록 (진행중) */}
{keptSchedules.length > 0 && (
<div>
<p className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-amber-600 sm:text-sm">
<Play className="h-3.5 w-3.5" />
({keptSchedules.length})
</p>
<div className="space-y-2">
{keptSchedules.map((item, idx) => {
const statusInfo = statusOptions.find((s) => s.value === item.status);
return (
<div key={idx} className="rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2 dark:border-amber-800 dark:bg-amber-950/30">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold sm:text-sm">
{item.item_code} - {item.item_name}
</p>
<span
className="rounded-md px-2 py-0.5 text-[10px] font-medium text-white sm:text-xs"
style={{ backgroundColor: statusInfo?.color || "#f59e0b" }}
>
{statusInfo?.label || item.status}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground sm:text-sm">
{item.plan_no} | : {Number(item.plan_qty || 0).toLocaleString()} EA
{item.completed_qty ? ` (완료: ${Number(item.completed_qty).toLocaleString()} EA)` : ""}
</p>
<div className="mt-0.5 flex flex-wrap gap-x-4 text-[10px] text-muted-foreground sm:text-xs">
<span>: {formatDate(item.start_date)}</span>
<span>: {formatDate(item.end_date)}</span>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isApplying}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<X className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
onClick={onConfirm}
disabled={isLoading || isApplying || !summary || previews.length === 0}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isApplying ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Check className="mr-1 h-3.5 w-3.5" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,3 +2,5 @@ export { TimelineHeader } from "./TimelineHeader";
export { ScheduleBar } from "./ScheduleBar";
export { ResourceRow } from "./ResourceRow";
export { TimelineLegend } from "./TimelineLegend";
export { ItemTimelineCard, groupSchedulesByItem } from "./ItemTimelineCard";
export { SchedulePreviewDialog } from "./SchedulePreviewDialog";

View File

@@ -225,6 +225,35 @@ export interface TimelineSchedulerConfig extends ComponentConfig {
/** 최대 높이 */
maxHeight?: number | string;
/**
* 표시 모드
* - "resource": 기존 설비(리소스) 기반 간트 차트 (기본값)
* - "itemGrouped": 품목별 카드형 타임라인 (참고 이미지 스타일)
*/
viewMode?: "resource" | "itemGrouped";
/** 범례 표시 여부 */
showLegend?: boolean;
/**
* 연결 필터 설정: 다른 컴포넌트의 선택에 따라 데이터를 필터링
* 설정 시 초기 상태는 빈 화면, 선택 이벤트 수신 시 필터링된 데이터 표시
*/
linkedFilter?: {
/** 소스 컴포넌트 ID (선택 이벤트를 발생시키는 컴포넌트) */
sourceComponentId?: string;
/** 소스 테이블명 (이벤트의 tableName과 매칭) */
sourceTableName?: string;
/** 소스 필드 (선택된 행에서 추출할 필드) */
sourceField: string;
/** 타겟 필드 (타임라인 데이터에서 필터링할 필드) */
targetField: string;
/** 선택 없을 때 빈 상태 표시 여부 (기본: true) */
showEmptyWhenNoSelection?: boolean;
/** 빈 상태 메시지 */
emptyMessage?: string;
};
}
/**