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[]; } /**