/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * GanttToolbar — play/pause, timeline scrubber, speed control, * work-schedule selector, and animation toggle. */ import { useCallback, useMemo } from 'react'; import { Play, Pause, SkipBack, SkipForward, Repeat, Repeat2, Gauge, Calendar, CalendarPlus, Plus, X, Trash2, Undo2, Redo2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { useViewerStore, countGeneratedTasks } from '@/store'; import type { GanttTimeScale } from '@/store'; import { toast } from '@/components/ui/toast'; import { formatDateTime } from './schedule-utils'; import { AnimationSettingsPopover } from './AnimationSettingsPopover'; interface GanttToolbarProps { onClose?: () => void; onOpenGenerate?: () => void; canGenerate?: boolean; } const SPEED_OPTIONS: Array<{ value: number; label: string }> = [ { value: 0.5, label: '0.5 d/s' }, { value: 1, label: '1 d/s' }, { value: 3, label: '3 d/s' }, { value: 7, label: '1 w/s' }, { value: 30, label: '1 mo/s' }, { value: 90, label: '3 mo/s' }, ]; const SCALE_OPTIONS: Array<{ value: GanttTimeScale; label: string }> = [ { value: 'hour', label: 'Hour' }, { value: 'day', label: 'Day' }, { value: 'week', label: 'Week' }, { value: 'month', label: 'Month' }, { value: 'year', label: 'Year' }, ]; // Radix Select rejects '' as a SelectItem value — use a sentinel for the // "All tasks" option and translate at the API boundary. const ALL_SCHEDULES_SENTINEL = '__all__'; export function GanttToolbar({ onClose, onOpenGenerate, canGenerate }: GanttToolbarProps) { const scheduleData = useViewerStore(s => s.scheduleData); const scheduleRange = useViewerStore(s => s.scheduleRange); const activeWorkScheduleId = useViewerStore(s => s.activeWorkScheduleId); const setActiveWorkScheduleId = useViewerStore(s => s.setActiveWorkScheduleId); const isPlaying = useViewerStore(s => s.playbackIsPlaying); const playbackTime = useViewerStore(s => s.playbackTime); const playbackSpeed = useViewerStore(s => s.playbackSpeed); const playbackLoop = useViewerStore(s => s.playbackLoop); const animationEnabled = useViewerStore(s => s.animationEnabled); const pendingGeneratedCount = useViewerStore(s => countGeneratedTasks(s.scheduleData)); const clearGeneratedSchedule = useViewerStore(s => s.clearGeneratedSchedule); const undoDepth = useViewerStore(s => s.scheduleUndoStack.length); const redoDepth = useViewerStore(s => s.scheduleRedoStack.length); const undoScheduleEdit = useViewerStore(s => s.undoScheduleEdit); const redoScheduleEdit = useViewerStore(s => s.redoScheduleEdit); const addTaskAction = useViewerStore(s => s.addTask); const selectedTaskGlobalIds = useViewerStore(s => s.selectedTaskGlobalIds); const scale = useViewerStore(s => s.ganttTimeScale); const togglePlay = useViewerStore(s => s.togglePlaySchedule); const pause = useViewerStore(s => s.pauseSchedule); const seek = useViewerStore(s => s.seekSchedule); const setSpeed = useViewerStore(s => s.setPlaybackSpeed); const setLoop = useViewerStore(s => s.setPlaybackLoop); const setAnimationEnabled = useViewerStore(s => s.setAnimationEnabled); const setScale = useViewerStore(s => s.setGanttTimeScale); const hasData = !!scheduleData && scheduleData.tasks.length > 0; const hasDates = !!scheduleRange && !scheduleRange.synthetic; const scheduleOptions = useMemo(() => { if (!scheduleData) return []; return [ { value: ALL_SCHEDULES_SENTINEL, label: 'All tasks' }, ...scheduleData.workSchedules.map(s => ({ value: s.globalId, label: s.name || s.globalId, })), ]; }, [scheduleData]); const selectedScheduleValue = activeWorkScheduleId || ALL_SCHEDULES_SENTINEL; const handleScheduleChange = useCallback((value: string) => { setActiveWorkScheduleId(value === ALL_SCHEDULES_SENTINEL ? '' : value); }, [setActiveWorkScheduleId]); const scrubPercent = useMemo(() => { if (!scheduleRange) return 0; const span = scheduleRange.end - scheduleRange.start; if (span <= 0) return 0; return Math.min(100, Math.max(0, ((playbackTime - scheduleRange.start) / span) * 100)); }, [scheduleRange, playbackTime]); const onScrubInput = useCallback((e: React.ChangeEvent) => { if (!scheduleRange) return; const pct = parseFloat(e.target.value) / 100; seek(scheduleRange.start + pct * (scheduleRange.end - scheduleRange.start)); }, [scheduleRange, seek]); const onScrubPointerDown = useCallback(() => { if (isPlaying) pause(); }, [isPlaying, pause]); const goStart = useCallback(() => { if (scheduleRange) seek(scheduleRange.start); }, [scheduleRange, seek]); const goEnd = useCallback(() => { if (scheduleRange) seek(scheduleRange.end); }, [scheduleRange, seek]); return (
Jump to start {isPlaying ? 'Pause' : 'Play'} construction sequence Jump to finish {playbackLoop ? 'Looping' : 'One-shot'}
{/* Scrub bar */}
{hasData ? formatDateTime(playbackTime) : '—'}
{/* Work schedule dropdown */}
{/* Speed */}
Simulation speed
{/* Scale */} {/* Generate from spatial hierarchy */} {onOpenGenerate && ( {canGenerate ? 'Generate schedule…' : 'No spatial hierarchy or geometry to generate from'} )} {/* + Task — insert a new task after the currently-selected row (or at the end when none is selected). Auto-selects the new task so the Inspector's Task card lights up for rename. */} {hasData && ( Add task (after selection or at end) )} {/* Undo / Redo for schedule edits. Gated on stack depth so the buttons only appear when there's actually something to undo — avoids a persistent greyed-out pair on clean schedules. */} {(undoDepth > 0 || redoDepth > 0) && (
Undo (Ctrl+Z) Redo (Ctrl+Shift+Z)
)} {/* Discard pending generated schedule — only visible when at least one locally-generated task exists. Keeps extracted tasks intact so partial-authoring workflows can still revert just the pending tail. */} {pendingGeneratedCount > 0 && ( Discard {pendingGeneratedCount} pending schedule task{pendingGeneratedCount === 1 ? '' : 's'} )} {/* Animation settings popover (replaces the bare toggle — gives the user access to lifecycle colour / palette / preparation window). */} setAnimationEnabled(!animationEnabled)} /> {onClose && ( )} {hasData && !hasDates && ( No dates )}
); }