From 1a319d178585abfc570a7768e073c9d8842a2424 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 16 Mar 2026 14:51:34 +0900 Subject: [PATCH] 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 --- .../V2TimelineSchedulerConfigPanel.tsx | 651 +++++++++++++++++- .../TimelineSchedulerComponent.tsx | 400 +++++------ .../v2-timeline-scheduler/config.ts | 35 +- .../components/v2-timeline-scheduler/types.ts | 55 ++ 4 files changed, 918 insertions(+), 223 deletions(-) diff --git a/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx b/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx index 11815db8..44065912 100644 --- a/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2TimelineSchedulerConfigPanel.tsx @@ -15,11 +15,11 @@ import { Badge } from "@/components/ui/badge"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; -import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers } from "lucide-react"; +import { Settings, ChevronDown, Check, ChevronsUpDown, Database, Users, Layers, Filter, Link, Zap, Trash2, Plus, GripVertical } from "lucide-react"; import { cn } from "@/lib/utils"; import { tableTypeApi } from "@/lib/api/screen"; -import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel } from "@/lib/registry/components/v2-timeline-scheduler/types"; -import { zoomLevelOptions, scheduleTypeOptions } from "@/lib/registry/components/v2-timeline-scheduler/config"; +import type { TimelineSchedulerConfig, ScheduleType, SourceDataConfig, ResourceFieldMapping, FieldMapping, ZoomLevel, ToolbarAction } from "@/lib/registry/components/v2-timeline-scheduler/types"; +import { zoomLevelOptions, scheduleTypeOptions, viewModeOptions, dataSourceOptions, toolbarIconOptions } from "@/lib/registry/components/v2-timeline-scheduler/config"; interface V2TimelineSchedulerConfigPanelProps { config: TimelineSchedulerConfig; @@ -49,10 +49,16 @@ export const V2TimelineSchedulerConfigPanel: React.FC(null); useEffect(() => { const loadTables = async () => { @@ -225,6 +231,31 @@ export const V2TimelineSchedulerConfigPanel: React.FC + {/* 뷰 모드 */} +
+
+

표시 모드

+

+ {viewModeOptions.find((o) => o.value === (config.viewMode || "resource"))?.description} +

+
+ +
+ {/* 커스텀 테이블 사용 여부 */}
@@ -470,6 +501,210 @@ export const V2TimelineSchedulerConfigPanel: React.FC + {/* ─── 필터 & 연동 설정 ─── */} + + + + + +
+ {/* 정적 필터 */} +
+

정적 필터 (staticFilters)

+

데이터 조회 시 항상 적용되는 고정 필터 조건

+ + {Object.entries(config.staticFilters || {}).map(([key, value]) => ( +
+ + = + + +
+ ))} + +
+ setNewFilterKey(e.target.value)} + placeholder="필드명 (예: product_type)" + className="h-7 flex-1 text-xs" + /> + = + setNewFilterValue(e.target.value)} + placeholder="값 (예: 완제품)" + className="h-7 flex-1 text-xs" + /> + +
+
+ + {/* 구분선 */} +
+ + {/* 연결 필터 */} +
+
+
+

+ + 연결 필터 (linkedFilter) +

+

다른 컴포넌트 선택에 따라 데이터를 필터링

+
+ { + if (v) { + updateConfig({ + linkedFilter: { + sourceField: "", + targetField: "", + showEmptyWhenNoSelection: true, + emptyMessage: "좌측 목록에서 항목을 선택하세요", + }, + }); + } else { + updateConfig({ linkedFilter: undefined }); + } + }} + /> +
+ + {config.linkedFilter && ( +
+
+
+ 소스 테이블명 +

선택 이벤트의 tableName 매칭

+
+ + + + + + value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}> + + + 없음 + + {tables.map((table) => ( + { + updateConfig({ + linkedFilter: { ...config.linkedFilter!, sourceTableName: table.tableName }, + }); + setLinkedFilterTableOpen(false); + }} + className="text-xs" + > + + {table.displayName} + + ))} + + + + + +
+ +
+ 소스 필드 (sourceField) * + updateConfig({ linkedFilter: { ...config.linkedFilter!, sourceField: e.target.value } })} + placeholder="예: part_code" + className="h-7 w-[140px] text-xs" + /> +
+ +
+ 타겟 필드 (targetField) * + updateConfig({ linkedFilter: { ...config.linkedFilter!, targetField: e.target.value } })} + placeholder="예: item_code" + className="h-7 w-[140px] text-xs" + /> +
+ +
+ 빈 상태 메시지 + updateConfig({ linkedFilter: { ...config.linkedFilter!, emptyMessage: e.target.value } })} + placeholder="선택 안내 문구" + className="h-7 w-[180px] text-xs" + /> +
+ +
+ 선택 없을 때 빈 화면 + updateConfig({ linkedFilter: { ...config.linkedFilter!, showEmptyWhenNoSelection: v } })} + /> +
+
+ )} +
+
+ + + {/* ─── 2단계: 소스 데이터 설정 ─── */} @@ -1038,6 +1273,17 @@ export const V2TimelineSchedulerConfigPanel: React.FC updateConfig({ showAddButton: v })} />
+ +
+
+

범례 표시

+

상태별 색상 범례를 보여줘요

+
+ updateConfig({ showLegend: v })} + /> +
@@ -1114,6 +1360,405 @@ export const V2TimelineSchedulerConfigPanel: React.FC + {/* ─── 6단계: 툴바 액션 설정 ─── */} + + + + + +
+

+ 툴바에 커스텀 버튼을 추가하여 API 호출 (미리보기 → 확인 → 적용) 워크플로우를 구성해요 +

+ + {/* 기존 액션 목록 */} + {(config.toolbarActions || []).map((action, index) => ( + setExpandedActionId(open ? action.id : null)} + > +
+ + + +
+ + + +
+ {/* 기본 설정 */} +
+
+ 버튼명 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], label: e.target.value }; + updateConfig({ toolbarActions: updated }); + }} + className="h-7 text-xs" + /> +
+
+ 아이콘 + +
+
+ +
+ 버튼 색상 (Tailwind 클래스) + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], color: e.target.value }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="예: bg-emerald-600 hover:bg-emerald-700" + className="h-7 text-xs" + /> +
+ + {/* API 설정 */} +
+

API 설정

+
+
+ 미리보기 API * + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], previewApi: e.target.value }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="/production/generate-schedule/preview" + className="h-7 text-xs" + /> +
+
+ 적용 API * + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], applyApi: e.target.value }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="/production/generate-schedule" + className="h-7 text-xs" + /> +
+
+
+ + {/* 다이얼로그 설정 */} +
+

다이얼로그

+
+
+ 제목 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], dialogTitle: e.target.value }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="자동 생성" + className="h-7 text-xs" + /> +
+
+ 설명 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], dialogDescription: e.target.value }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="미리보기 후 확인하여 적용합니다" + className="h-7 text-xs" + /> +
+
+
+ + {/* 데이터 소스 설정 */} +
+

데이터 소스

+
+
+ 데이터 소스 유형 * + +
+ + {action.dataSource === "linkedSelection" && ( +
+
+
+ 그룹 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, groupByField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="linkedFilter.sourceField 사용" + className="h-7 text-xs" + /> +
+
+ 수량 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, quantityField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="balance_qty" + className="h-7 text-xs" + /> +
+
+
+
+ 기준일 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, dueDateField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="due_date" + className="h-7 text-xs" + /> +
+
+ 표시명 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, nameField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="part_name" + className="h-7 text-xs" + /> +
+
+
+ )} + + {action.dataSource === "currentSchedules" && ( +
+
+
+ 필터 필드 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterField: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="product_type" + className="h-7 text-xs" + /> +
+
+ 필터 값 + { + const updated = [...(config.toolbarActions || [])]; + updated[index] = { ...updated[index], payloadConfig: { ...updated[index].payloadConfig, scheduleFilterValue: e.target.value || undefined } }; + updateConfig({ toolbarActions: updated }); + }} + placeholder="완제품" + className="h-7 text-xs" + /> +
+
+
+ )} +
+
+ + {/* 표시 조건 */} +
+

표시 조건 (showWhen)

+

staticFilters 값과 비교하여 일치할 때만 버튼 표시

+ {Object.entries(action.showWhen || {}).map(([key, value]) => ( +
+ + = + + +
+ ))} +
+ + = + + +
+
+
+
+
+
+ ))} + + {/* 액션 추가 버튼 */} + +
+ + ); }; diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx index 3a69da65..075e8eca 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx +++ b/frontend/lib/registry/components/v2-timeline-scheduler/TimelineSchedulerComponent.tsx @@ -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(null); - // ────────── 자동 스케줄 생성 상태 ────────── - const [showPreviewDialog, setShowPreviewDialog] = useState(false); - const [previewLoading, setPreviewLoading] = useState(false); - const [previewApplying, setPreviewApplying] = useState(false); - const [previewSummary, setPreviewSummary] = useState(null); - const [previewItems, setPreviewItems] = useState([]); - const [previewDeleted, setPreviewDeleted] = useState([]); - const [previewKept, setPreviewKept] = useState([]); + // ────────── 툴바 액션 다이얼로그 상태 (통합) ────────── + 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([]); - // ────────── 반제품 계획 생성 상태 ────────── - const [showSemiPreviewDialog, setShowSemiPreviewDialog] = useState(false); - const [semiPreviewLoading, setSemiPreviewLoading] = useState(false); - const [semiPreviewApplying, setSemiPreviewApplying] = useState(false); - const [semiPreviewSummary, setSemiPreviewSummary] = useState(null); - const [semiPreviewItems, setSemiPreviewItems] = useState([]); - const [semiPreviewDeleted, setSemiPreviewDeleted] = useState([]); - const [semiPreviewKept, setSemiPreviewKept] = useState([]); + // ────────── 아이콘 맵 ────────── + const TOOLBAR_ICONS: Record> = 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(); - 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(); + 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({ 새로고침 - {config.staticFilters?.product_type === "완제품" && ( - <> - - - - )} + ); + })} )} @@ -796,33 +769,22 @@ export function TimelineSchedulerComponent({ )} - {/* 완제품 스케줄 생성 미리보기 다이얼로그 */} - - - {/* 반제품 계획 생성 미리보기 다이얼로그 */} - + {/* 범용 액션 미리보기 다이얼로그 */} + {actionDialog && ( + { 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} + /> + )} ); } diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/config.ts b/frontend/lib/registry/components/v2-timeline-scheduler/config.ts index 17c31991..57409191 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/config.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/config.ts @@ -1,6 +1,6 @@ "use client"; -import { TimelineSchedulerConfig, ZoomLevel, ScheduleType } from "./types"; +import { TimelineSchedulerConfig, ZoomLevel, ScheduleType, ToolbarAction } from "./types"; /** * 기본 타임라인 스케줄러 설정 @@ -94,6 +94,39 @@ export const scheduleTypeOptions: { value: ScheduleType; label: string }[] = [ { value: "WORK_ASSIGN", label: "작업배정" }, ]; +/** + * 뷰 모드 옵션 + */ +export const viewModeOptions: { value: string; label: string; description: string }[] = [ + { value: "resource", label: "리소스 기반", description: "설비/작업자 행 기반 간트차트" }, + { value: "itemGrouped", label: "품목별 그룹", description: "품목별 카드형 타임라인" }, +]; + +/** + * 데이터 소스 옵션 + */ +export const dataSourceOptions: { value: string; label: string; description: string }[] = [ + { value: "linkedSelection", label: "연결 필터 선택값", description: "좌측 테이블에서 선택된 행 데이터 사용" }, + { value: "currentSchedules", label: "현재 스케줄", description: "타임라인에 표시 중인 스케줄 ID 사용" }, +]; + +/** + * 아이콘 옵션 + */ +export const toolbarIconOptions: { value: string; label: string }[] = [ + { value: "Zap", label: "Zap (번개)" }, + { value: "Package", label: "Package (박스)" }, + { value: "Plus", label: "Plus (추가)" }, + { value: "Download", label: "Download (다운로드)" }, + { value: "Upload", label: "Upload (업로드)" }, + { value: "RefreshCw", label: "RefreshCw (새로고침)" }, + { value: "Play", label: "Play (재생)" }, + { value: "FileText", label: "FileText (문서)" }, + { value: "Send", label: "Send (전송)" }, + { value: "Sparkles", label: "Sparkles (반짝)" }, + { value: "Wand2", label: "Wand2 (마법봉)" }, +]; + /** * 줌 레벨별 표시 일수 */ diff --git a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts index aa5c4edd..5c0ef953 100644 --- a/frontend/lib/registry/components/v2-timeline-scheduler/types.ts +++ b/frontend/lib/registry/components/v2-timeline-scheduler/types.ts @@ -128,6 +128,58 @@ export interface SourceDataConfig { groupNameField?: string; } +/** + * 툴바 액션 설정 (커스텀 버튼) + * 타임라인 툴바에 표시되는 커스텀 액션 버튼을 정의 + * preview -> confirm -> apply 워크플로우 지원 + */ +export interface ToolbarAction { + /** 고유 ID */ + id: string; + /** 버튼 텍스트 */ + label: string; + /** lucide-react 아이콘명 */ + icon?: "Zap" | "Package" | "Plus" | "Download" | "Upload" | "RefreshCw" | "Play" | "FileText" | "Send" | "Sparkles" | "Wand2"; + /** 버튼 색상 클래스 (예: "bg-emerald-600 hover:bg-emerald-700") */ + color?: string; + /** 미리보기 API 엔드포인트 (예: "/production/generate-schedule/preview") */ + previewApi: string; + /** 적용 API 엔드포인트 (예: "/production/generate-schedule") */ + applyApi: string; + /** 다이얼로그 제목 */ + dialogTitle?: string; + /** 다이얼로그 설명 */ + dialogDescription?: string; + /** + * 데이터 소스 유형 + * - linkedSelection: 연결 필터(좌측 테이블)에서 선택된 행 사용 + * - currentSchedules: 현재 타임라인의 스케줄 ID 사용 + */ + dataSource: "linkedSelection" | "currentSchedules"; + /** 페이로드 구성 설정 */ + payloadConfig?: { + /** linkedSelection: 선택된 행을 그룹화할 필드 (기본: linkedFilter.sourceField) */ + groupByField?: string; + /** linkedSelection: 수량 합계 필드 (예: "balance_qty") */ + quantityField?: string; + /** linkedSelection: 기준일 필드 (예: "due_date") */ + dueDateField?: string; + /** linkedSelection: 표시명 필드 (예: "part_name") */ + nameField?: string; + /** currentSchedules: 스케줄 필터 조건 필드명 (예: "product_type") */ + scheduleFilterField?: string; + /** currentSchedules: 스케줄 필터 값 (예: "완제품") */ + scheduleFilterValue?: string; + /** API 호출 시 추가 옵션 (예: { "safety_lead_time": 1 }) */ + extraOptions?: Record; + }; + /** + * 표시 조건: staticFilters와 비교하여 모든 조건이 일치할 때만 버튼 표시 + * 예: { "product_type": "완제품" } → staticFilters.product_type === "완제품"일 때만 표시 + */ + showWhen?: Record; +} + /** * 타임라인 스케줄러 설정 */ @@ -254,6 +306,9 @@ export interface TimelineSchedulerConfig extends ComponentConfig { /** 빈 상태 메시지 */ emptyMessage?: string; }; + + /** 툴바 커스텀 액션 버튼 설정 */ + toolbarActions?: ToolbarAction[]; } /**