feat: enhance V2TimelineSchedulerConfigPanel with filter and view mode options

- Added new filter and linking settings section to the V2TimelineSchedulerConfigPanel, allowing users to manage static filters and linked filters more effectively.
- Introduced view mode options to switch between different display modes in the timeline scheduler.
- Updated the configuration types and added new toolbar action settings to support custom actions in the timeline toolbar.
- Enhanced the overall user experience by providing more flexible filtering and display options.

These updates aim to improve the functionality and usability of the timeline scheduler within the ERP system, enabling better data management and visualization.

Made-with: Cursor
This commit is contained in:
kjs
2026-03-16 14:51:34 +09:00
parent 64c9f25f63
commit 1a319d1785
4 changed files with 918 additions and 223 deletions

View File

@@ -12,7 +12,15 @@ import {
Package,
Zap,
RefreshCw,
Download,
Upload,
Play,
FileText,
Send,
Sparkles,
Wand2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useVirtualizer } from "@tanstack/react-virtual";
@@ -20,6 +28,7 @@ import {
TimelineSchedulerComponentProps,
ScheduleItem,
ZoomLevel,
ToolbarAction,
} from "./types";
import { useTimelineData } from "./hooks/useTimelineData";
import { TimelineHeader, ResourceRow, TimelineLegend, ItemTimelineCard, groupSchedulesByItem, SchedulePreviewDialog } from "./components";
@@ -53,24 +62,24 @@ 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 [actionDialog, setActionDialog] = useState<{
actionId: string;
action: ToolbarAction;
isLoading: boolean;
isApplying: boolean;
summary: any;
previews: any[];
deletedSchedules: any[];
keptSchedules: any[];
preparedPayload: any;
} | null>(null);
const linkedFilterValuesRef = useRef<any[]>([]);
// ────────── 반제품 계획 생성 상태 ──────────
const [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[]>([]);
// ────────── 아이콘 맵 ──────────
const TOOLBAR_ICONS: Record<string, React.ComponentType<{ className?: string }>> = useMemo(() => ({
Zap, Package, Plus, Download, Upload, RefreshCw, Play, FileText, Send, Sparkles, Wand2,
}), []);
// ────────── linkedFilter 상태 ──────────
const linkedFilter = config.linkedFilter;
@@ -339,197 +348,153 @@ export function TimelineSchedulerComponent({
}
}, [onAddSchedule, effectiveResources]);
// ────────── 자동 스케줄 생성: 미리보기 요청 ──────────
const handleAutoSchedulePreview = useCallback(async () => {
const selectedRows = linkedFilterValuesRef.current;
if (!selectedRows || selectedRows.length === 0) {
toast.warning("좌측에서 품목을 선택해주세요");
return;
// ────────── 유효 툴바 액션 (config 기반 또는 하위호환 자동생성) ──────────
const effectiveToolbarActions: ToolbarAction[] = useMemo(() => {
if (config.toolbarActions && config.toolbarActions.length > 0) {
return config.toolbarActions;
}
return [];
}, [config.toolbarActions]);
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 handleActionPreview = useCallback(async (action: ToolbarAction) => {
let payload: any;
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];
if (action.dataSource === "linkedSelection") {
const selectedRows = linkedFilterValuesRef.current;
if (!selectedRows || selectedRows.length === 0) {
toast.warning("좌측에서 항목을 선택해주세요");
return;
}
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);
const groupField = action.payloadConfig?.groupByField || config.linkedFilter?.sourceField || "part_code";
const qtyField = action.payloadConfig?.quantityField || config.sourceConfig?.quantityField || "balance_qty";
const dateField = action.payloadConfig?.dueDateField || config.sourceConfig?.dueDateField || "due_date";
const nameField = action.payloadConfig?.nameField || config.sourceConfig?.groupNameField || "part_name";
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,
},
const grouped = new Map<string, any[]>();
selectedRows.forEach((row: any) => {
const key = row[groupField] || "";
if (!key) return;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(row);
});
const items = Array.from(grouped.entries()).map(([code, rows]) => {
const totalQty = rows.reduce((sum: number, r: any) => sum + (Number(r[qtyField]) || 0), 0);
const dates = rows.map((r: any) => r[dateField]).filter(Boolean).sort();
const earliestDate = dates[0] || new Date().toISOString().split("T")[0];
const first = rows[0];
return {
item_code: code,
item_name: first[nameField] || first.item_name || code,
required_qty: totalQty,
earliest_due_date: typeof earliestDate === "string" ? earliestDate.split("T")[0] : earliestDate,
hourly_capacity: Number(first.hourly_capacity) || undefined,
daily_capacity: Number(first.daily_capacity) || undefined,
};
}).filter((item) => item.required_qty > 0);
if (items.length === 0) {
toast.warning("선택된 항목의 잔량이 없습니다");
return;
}
payload = {
items,
options: {
...(config.staticFilters || {}),
...(action.payloadConfig?.extraOptions || {}),
},
};
} else if (action.dataSource === "currentSchedules") {
let targetSchedules = schedules;
const filterField = action.payloadConfig?.scheduleFilterField;
const filterValue = action.payloadConfig?.scheduleFilterValue;
if (filterField && filterValue) {
targetSchedules = schedules.filter((s) => {
const val = (s.data as any)?.[filterField] || "";
return val === filterValue;
});
}
if (targetSchedules.length === 0) {
toast.warning("대상 스케줄이 없습니다");
return;
}
const planIds = targetSchedules.map((s) => Number(s.id)).filter((id) => !isNaN(id));
if (planIds.length === 0) {
toast.warning("유효한 스케줄 ID가 없습니다");
return;
}
payload = {
plan_ids: planIds,
options: action.payloadConfig?.extraOptions || {},
};
}
setActionDialog({
actionId: action.id,
action,
isLoading: true,
isApplying: false,
summary: null,
previews: [],
deletedSchedules: [],
keptSchedules: [],
preparedPayload: payload,
});
try {
const response = await apiClient.post(action.previewApi, payload);
if (response.data?.success) {
setPreviewSummary(response.data.data.summary);
setPreviewItems(response.data.data.previews);
setPreviewDeleted(response.data.data.deletedSchedules || []);
setPreviewKept(response.data.data.keptSchedules || []);
setActionDialog((prev) => prev ? {
...prev,
isLoading: false,
summary: response.data.data.summary,
previews: response.data.data.previews || [],
deletedSchedules: response.data.data.deletedSchedules || [],
keptSchedules: response.data.data.keptSchedules || [],
} : null);
} else {
toast.error("미리보기 생성 실패");
setShowPreviewDialog(false);
toast.error("미리보기 생성 실패", { description: response.data?.message });
setActionDialog(null);
}
} catch (err: any) {
toast.error("미리보기 요청 실패", { description: err.message });
setShowPreviewDialog(false);
} finally {
setPreviewLoading(false);
setActionDialog(null);
}
}, [config.linkedFilter, config.staticFilters]);
}, [config.linkedFilter, config.staticFilters, config.sourceConfig, schedules]);
// ────────── 자동 스케줄 생성: 확인 및 적용 ──────────
const handleAutoScheduleApply = useCallback(async () => {
if (!previewItems || previewItems.length === 0) return;
// ────────── 범용 액션: 확인 및 적용 ──────────
const handleActionApply = useCallback(async () => {
if (!actionDialog) return;
const { action, preparedPayload } = actionDialog;
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,
}));
setActionDialog((prev) => prev ? { ...prev, isApplying: true } : null);
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 },
});
const response = await apiClient.post(action.applyApi, preparedPayload);
if (response.data?.success) {
const data = response.data.data;
toast.success("반제품 계획 생성 완료", {
description: `${data.count}건의 반제품 계획이 생성되었습니다`,
const summary = data.summary || data;
toast.success(action.dialogTitle || "완료", {
description: `신규: ${summary.new_count || summary.count || 0}${summary.kept_count ? `, 유지: ${summary.kept_count}` : ""}${summary.deleted_count ? `, 삭제: ${summary.deleted_count}` : ""}`,
});
setShowSemiPreviewDialog(false);
setActionDialog(null);
refreshTimeline();
} else {
toast.error("반제품 계획 생성 실패");
toast.error("실행 실패", { description: response.data?.message });
}
} catch (err: any) {
toast.error("반제품 계획 생성 실패", { description: err.message });
toast.error("실행 실패", { description: err.message });
} finally {
setSemiPreviewApplying(false);
setActionDialog((prev) => prev ? { ...prev, isApplying: false } : null);
}
}, [schedules, refreshTimeline]);
}, [actionDialog, refreshTimeline]);
// ────────── 하단 영역 높이 계산 (툴바 + 범례) ──────────
const showToolbar = config.showToolbar !== false;
@@ -713,18 +678,26 @@ export function TimelineSchedulerComponent({
<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" />
{effectiveToolbarActions.map((action) => {
if (action.showWhen) {
const matches = Object.entries(action.showWhen).every(
([key, value]) => config.staticFilters?.[key] === value
);
if (!matches) return null;
}
const IconComp = TOOLBAR_ICONS[action.icon || "Zap"] || Zap;
return (
<Button
key={action.id}
size="sm"
onClick={() => handleActionPreview(action)}
className={cn("h-6 gap-1 px-2 text-[10px] sm:h-7 sm:px-3 sm:text-xs", action.color || "bg-primary hover:bg-primary/90")}
>
<IconComp className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
{action.label}
</Button>
<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>
)}
@@ -796,33 +769,22 @@ export function TimelineSchedulerComponent({
</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 기반으로 완제품 계획에 필요한 반제품 생산계획을 생성합니다"
/>
{/* 범용 액션 미리보기 다이얼로그 */}
{actionDialog && (
<SchedulePreviewDialog
open={true}
onOpenChange={(open) => { if (!open) setActionDialog(null); }}
isLoading={actionDialog.isLoading}
summary={actionDialog.summary}
previews={actionDialog.previews}
deletedSchedules={actionDialog.deletedSchedules}
keptSchedules={actionDialog.keptSchedules}
onConfirm={handleActionApply}
isApplying={actionDialog.isApplying}
title={actionDialog.action.dialogTitle}
description={actionDialog.action.dialogDescription}
/>
)}
</div>
);
}