- Custom Fields (FEAT-001): - CustomField and TaskCustomValue models with formula support - CRUD API for custom field management - Formula engine for calculated fields - Frontend: CustomFieldEditor, CustomFieldInput, ProjectSettings page - Task list API now includes custom_values - KanbanBoard displays custom field values - Gantt View (FEAT-003): - TaskDependency model with FS/SS/FF/SF dependency types - Dependency CRUD API with cycle detection - start_date field added to tasks - GanttChart component with Frappe Gantt integration - Dependency type selector in UI - Calendar View (FEAT-004): - CalendarView component with FullCalendar integration - Date range filtering API for tasks - Drag-and-drop date updates - View mode switching in Tasks page - File Encryption (FEAT-010): - AES-256-GCM encryption service - EncryptionKey model with key rotation support - Admin API for key management - Encrypted upload/download for confidential projects - Migrations: 011 (custom fields), 012 (encryption keys), 013 (task dependencies) - Updated issues.md with completion status 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
984 lines
33 KiB
TypeScript
984 lines
33 KiB
TypeScript
import { useEffect, useRef, useState, useCallback } from 'react'
|
|
import Gantt, { GanttTask, ViewMode } from 'frappe-gantt'
|
|
import api from '../services/api'
|
|
import { dependenciesApi, TaskDependency, DependencyType } from '../services/dependencies'
|
|
|
|
interface Task {
|
|
id: string
|
|
project_id: string
|
|
title: string
|
|
description: string | null
|
|
priority: string
|
|
status_id: string | null
|
|
status_name: string | null
|
|
status_color: string | null
|
|
assignee_id: string | null
|
|
assignee_name: string | null
|
|
due_date: string | null
|
|
start_date: string | null
|
|
time_estimate: number | null
|
|
subtask_count: number
|
|
progress?: number
|
|
}
|
|
|
|
interface TaskStatus {
|
|
id: string
|
|
name: string
|
|
color: string
|
|
is_done: boolean
|
|
}
|
|
|
|
interface GanttChartProps {
|
|
projectId: string
|
|
tasks: Task[]
|
|
statuses: TaskStatus[]
|
|
onTaskClick: (task: Task) => void
|
|
onTaskUpdate: () => void
|
|
}
|
|
|
|
// Priority colors for custom styling
|
|
const priorityClasses: Record<string, string> = {
|
|
urgent: 'gantt-bar-urgent',
|
|
high: 'gantt-bar-high',
|
|
medium: 'gantt-bar-medium',
|
|
low: 'gantt-bar-low',
|
|
}
|
|
|
|
export function GanttChart({
|
|
projectId,
|
|
tasks,
|
|
statuses,
|
|
onTaskClick,
|
|
onTaskUpdate,
|
|
}: GanttChartProps) {
|
|
const ganttRef = useRef<HTMLDivElement>(null)
|
|
const ganttInstance = useRef<Gantt | null>(null)
|
|
const [viewMode, setViewMode] = useState<ViewMode>('Week')
|
|
const [dependencies, setDependencies] = useState<TaskDependency[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Dependency management state
|
|
const [showDependencyModal, setShowDependencyModal] = useState(false)
|
|
const [selectedTaskForDependency, setSelectedTaskForDependency] = useState<Task | null>(null)
|
|
const [selectedPredecessor, setSelectedPredecessor] = useState<string>('')
|
|
const [selectedDependencyType, setSelectedDependencyType] = useState<DependencyType>('FS')
|
|
const [dependencyError, setDependencyError] = useState<string | null>(null)
|
|
|
|
// Task data mapping for quick lookup
|
|
const taskMap = useRef<Map<string, Task>>(new Map())
|
|
|
|
// Load dependencies
|
|
useEffect(() => {
|
|
loadDependencies()
|
|
}, [projectId])
|
|
|
|
const loadDependencies = async () => {
|
|
try {
|
|
const deps = await dependenciesApi.getProjectDependencies(projectId)
|
|
setDependencies(deps)
|
|
} catch (err) {
|
|
console.error('Failed to load dependencies:', err)
|
|
}
|
|
}
|
|
|
|
// Transform tasks to Gantt format
|
|
const transformTasksToGantt = useCallback((): GanttTask[] => {
|
|
const today = new Date()
|
|
const defaultEndDate = new Date(today)
|
|
defaultEndDate.setDate(defaultEndDate.getDate() + 7)
|
|
|
|
// Update task map
|
|
taskMap.current.clear()
|
|
tasks.forEach((task) => taskMap.current.set(task.id, task))
|
|
|
|
// Build dependency string map (task_id -> comma-separated predecessor ids)
|
|
const dependencyMap = new Map<string, string[]>()
|
|
dependencies.forEach((dep) => {
|
|
const existing = dependencyMap.get(dep.successor_id) || []
|
|
existing.push(dep.predecessor_id)
|
|
dependencyMap.set(dep.successor_id, existing)
|
|
})
|
|
|
|
return tasks
|
|
.filter((task) => task.start_date || task.due_date) // Only tasks with dates
|
|
.map((task) => {
|
|
// Determine dates
|
|
let startDate: Date
|
|
let endDate: Date
|
|
|
|
if (task.start_date && task.due_date) {
|
|
startDate = new Date(task.start_date)
|
|
endDate = new Date(task.due_date)
|
|
} else if (task.start_date) {
|
|
startDate = new Date(task.start_date)
|
|
endDate = new Date(startDate)
|
|
endDate.setDate(endDate.getDate() + 7) // Default 7 days duration
|
|
} else if (task.due_date) {
|
|
endDate = new Date(task.due_date)
|
|
startDate = new Date(endDate)
|
|
startDate.setDate(startDate.getDate() - 7) // Default start 7 days before
|
|
} else {
|
|
startDate = today
|
|
endDate = defaultEndDate
|
|
}
|
|
|
|
// Calculate progress based on status
|
|
let progress = task.progress ?? 0
|
|
if (task.status_id) {
|
|
const status = statuses.find((s) => s.id === task.status_id)
|
|
if (status?.is_done) {
|
|
progress = 100
|
|
}
|
|
}
|
|
|
|
// Get dependencies for this task
|
|
const taskDeps = dependencyMap.get(task.id)
|
|
const dependencyString = taskDeps ? taskDeps.join(', ') : ''
|
|
|
|
// Custom class based on priority
|
|
const customClass = priorityClasses[task.priority] || priorityClasses.medium
|
|
|
|
return {
|
|
id: task.id,
|
|
name: task.title,
|
|
start: startDate,
|
|
end: endDate,
|
|
progress,
|
|
dependencies: dependencyString,
|
|
custom_class: customClass,
|
|
// Store original task data for reference
|
|
_task: task,
|
|
}
|
|
})
|
|
}, [tasks, statuses, dependencies])
|
|
|
|
// Initialize and update Gantt chart
|
|
useEffect(() => {
|
|
if (!ganttRef.current) return
|
|
|
|
const ganttTasks = transformTasksToGantt()
|
|
|
|
if (ganttTasks.length === 0) {
|
|
// Clear the gantt if no tasks
|
|
ganttInstance.current = null
|
|
return
|
|
}
|
|
|
|
// Create or update Gantt instance
|
|
if (!ganttInstance.current) {
|
|
// Clear container first
|
|
ganttRef.current.innerHTML = ''
|
|
|
|
ganttInstance.current = new Gantt(ganttRef.current, ganttTasks, {
|
|
header_height: 50,
|
|
column_width: 30,
|
|
step: 24,
|
|
view_modes: ['Day', 'Week', 'Month'],
|
|
bar_height: 24,
|
|
bar_corner_radius: 3,
|
|
arrow_curve: 5,
|
|
padding: 18,
|
|
view_mode: viewMode,
|
|
date_format: 'YYYY-MM-DD',
|
|
language: 'en',
|
|
custom_popup_html: (task: GanttTask) => {
|
|
const originalTask = taskMap.current.get(task.id)
|
|
if (!originalTask) return ''
|
|
|
|
const assignee = originalTask.assignee_name || 'Unassigned'
|
|
const statusName = originalTask.status_name || 'No Status'
|
|
const priority = originalTask.priority.charAt(0).toUpperCase() + originalTask.priority.slice(1)
|
|
|
|
return `
|
|
<div class="gantt-popup">
|
|
<h3 class="gantt-popup-title">${task.name}</h3>
|
|
<div class="gantt-popup-info">
|
|
<div class="gantt-popup-row">
|
|
<span class="gantt-popup-label">Assignee:</span>
|
|
<span class="gantt-popup-value">${assignee}</span>
|
|
</div>
|
|
<div class="gantt-popup-row">
|
|
<span class="gantt-popup-label">Status:</span>
|
|
<span class="gantt-popup-value">${statusName}</span>
|
|
</div>
|
|
<div class="gantt-popup-row">
|
|
<span class="gantt-popup-label">Priority:</span>
|
|
<span class="gantt-popup-value">${priority}</span>
|
|
</div>
|
|
<div class="gantt-popup-row">
|
|
<span class="gantt-popup-label">Progress:</span>
|
|
<span class="gantt-popup-value">${task.progress}%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`
|
|
},
|
|
on_click: (task: GanttTask) => {
|
|
const originalTask = taskMap.current.get(task.id)
|
|
if (originalTask) {
|
|
onTaskClick(originalTask)
|
|
}
|
|
},
|
|
on_date_change: async (task: GanttTask, start: Date, end: Date) => {
|
|
await handleDateChange(task.id, start, end)
|
|
},
|
|
on_progress_change: async (task: GanttTask, progress: number) => {
|
|
await handleProgressChange(task.id, progress)
|
|
},
|
|
})
|
|
} else {
|
|
// Refresh existing instance
|
|
ganttInstance.current.refresh(ganttTasks)
|
|
}
|
|
}, [tasks, dependencies, transformTasksToGantt, onTaskClick])
|
|
|
|
// Update view mode
|
|
useEffect(() => {
|
|
if (ganttInstance.current) {
|
|
ganttInstance.current.change_view_mode(viewMode)
|
|
}
|
|
}, [viewMode])
|
|
|
|
// Handle date change (drag/resize)
|
|
const handleDateChange = async (taskId: string, start: Date, end: Date) => {
|
|
setError(null)
|
|
setLoading(true)
|
|
|
|
// Format dates
|
|
const startDate = start.toISOString().split('T')[0]
|
|
const dueDate = end.toISOString().split('T')[0]
|
|
|
|
try {
|
|
await api.patch(`/tasks/${taskId}`, {
|
|
start_date: startDate,
|
|
due_date: dueDate,
|
|
})
|
|
onTaskUpdate()
|
|
} catch (err: unknown) {
|
|
console.error('Failed to update task dates:', err)
|
|
const error = err as { response?: { data?: { detail?: string } } }
|
|
const errorMessage = error.response?.data?.detail || 'Failed to update task dates'
|
|
setError(errorMessage)
|
|
// Refresh to rollback visual changes
|
|
onTaskUpdate()
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
// Handle progress change
|
|
const handleProgressChange = async (taskId: string, progress: number) => {
|
|
// Progress changes could update task status in the future
|
|
// For now, just log it
|
|
console.log(`Task ${taskId} progress changed to ${progress}%`)
|
|
}
|
|
|
|
// Add dependency
|
|
const handleAddDependency = async () => {
|
|
if (!selectedTaskForDependency || !selectedPredecessor) return
|
|
|
|
setDependencyError(null)
|
|
|
|
try {
|
|
await dependenciesApi.addDependency(selectedTaskForDependency.id, {
|
|
predecessor_id: selectedPredecessor,
|
|
dependency_type: selectedDependencyType,
|
|
})
|
|
await loadDependencies()
|
|
setShowDependencyModal(false)
|
|
setSelectedTaskForDependency(null)
|
|
setSelectedPredecessor('')
|
|
setSelectedDependencyType('FS')
|
|
} catch (err: unknown) {
|
|
console.error('Failed to add dependency:', err)
|
|
const error = err as { response?: { data?: { detail?: string } } }
|
|
const errorMessage = error.response?.data?.detail || 'Failed to add dependency'
|
|
setDependencyError(errorMessage)
|
|
}
|
|
}
|
|
|
|
// Remove dependency
|
|
const handleRemoveDependency = async (dependencyId: string) => {
|
|
try {
|
|
await dependenciesApi.removeDependency(dependencyId)
|
|
await loadDependencies()
|
|
} catch (err) {
|
|
console.error('Failed to remove dependency:', err)
|
|
setError('Failed to remove dependency')
|
|
}
|
|
}
|
|
|
|
// Open dependency modal for a task
|
|
const openDependencyModal = (task: Task) => {
|
|
setSelectedTaskForDependency(task)
|
|
setSelectedPredecessor('')
|
|
setDependencyError(null)
|
|
setShowDependencyModal(true)
|
|
}
|
|
|
|
// Get available predecessors (tasks that can be added as dependencies)
|
|
const getAvailablePredecessors = () => {
|
|
if (!selectedTaskForDependency) return []
|
|
|
|
// Get existing predecessor IDs for selected task
|
|
const existingPredecessorIds = new Set(
|
|
dependencies
|
|
.filter((d) => d.successor_id === selectedTaskForDependency.id)
|
|
.map((d) => d.predecessor_id)
|
|
)
|
|
|
|
// Filter out the selected task itself and already added predecessors
|
|
return tasks.filter(
|
|
(t) =>
|
|
t.id !== selectedTaskForDependency.id &&
|
|
!existingPredecessorIds.has(t.id) &&
|
|
(t.start_date || t.due_date) // Only tasks with dates
|
|
)
|
|
}
|
|
|
|
// Get current dependencies for selected task
|
|
const getCurrentDependencies = () => {
|
|
if (!selectedTaskForDependency) return []
|
|
return dependencies.filter((d) => d.successor_id === selectedTaskForDependency.id)
|
|
}
|
|
|
|
// Check if there are tasks with dates
|
|
const tasksWithDates = tasks.filter((t) => t.start_date || t.due_date)
|
|
|
|
return (
|
|
<div style={styles.container}>
|
|
{/* Toolbar */}
|
|
<div style={styles.toolbar}>
|
|
<div style={styles.viewModeButtons}>
|
|
<span style={styles.toolbarLabel}>Zoom:</span>
|
|
{(['Day', 'Week', 'Month'] as ViewMode[]).map((mode) => (
|
|
<button
|
|
key={mode}
|
|
onClick={() => setViewMode(mode)}
|
|
style={{
|
|
...styles.viewModeButton,
|
|
...(viewMode === mode ? styles.viewModeButtonActive : {}),
|
|
}}
|
|
>
|
|
{mode}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{loading && <span style={styles.loadingIndicator}>Saving...</span>}
|
|
{error && <span style={styles.errorIndicator}>{error}</span>}
|
|
</div>
|
|
|
|
{/* Gantt Chart */}
|
|
{tasksWithDates.length > 0 ? (
|
|
<div style={styles.ganttWrapper}>
|
|
<div ref={ganttRef} style={styles.ganttContainer} />
|
|
</div>
|
|
) : (
|
|
<div style={styles.emptyState}>
|
|
<p style={styles.emptyText}>No tasks with dates to display.</p>
|
|
<p style={styles.emptyHint}>
|
|
Add start dates and due dates to your tasks to see them on the Gantt chart.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Legend and Actions */}
|
|
<div style={styles.footer}>
|
|
<div style={styles.legend}>
|
|
<span style={styles.legendTitle}>Priority:</span>
|
|
<div style={styles.legendItem}>
|
|
<span style={{ ...styles.legendDot, backgroundColor: '#f44336' }} />
|
|
<span>Urgent</span>
|
|
</div>
|
|
<div style={styles.legendItem}>
|
|
<span style={{ ...styles.legendDot, backgroundColor: '#ff9800' }} />
|
|
<span>High</span>
|
|
</div>
|
|
<div style={styles.legendItem}>
|
|
<span style={{ ...styles.legendDot, backgroundColor: '#0066cc' }} />
|
|
<span>Medium</span>
|
|
</div>
|
|
<div style={styles.legendItem}>
|
|
<span style={{ ...styles.legendDot, backgroundColor: '#808080' }} />
|
|
<span>Low</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={styles.footerActions}>
|
|
<span style={styles.footerHint}>
|
|
Click a task to view details. Drag to change dates.
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dependencies Section */}
|
|
{tasksWithDates.length > 0 && (
|
|
<div style={styles.dependenciesSection}>
|
|
<h3 style={styles.dependenciesTitle}>Task Dependencies</h3>
|
|
<p style={styles.dependenciesHint}>
|
|
Select a task below to manage its dependencies.
|
|
</p>
|
|
|
|
<div style={styles.taskList}>
|
|
{tasksWithDates.map((task) => {
|
|
const taskDeps = dependencies.filter((d) => d.successor_id === task.id)
|
|
return (
|
|
<div key={task.id} style={styles.taskItem}>
|
|
<div style={styles.taskItemInfo}>
|
|
<span style={styles.taskItemTitle}>{task.title}</span>
|
|
{taskDeps.length > 0 && (
|
|
<span style={styles.taskItemDeps}>
|
|
Depends on: {taskDeps.map((d) => {
|
|
const predecessor = tasks.find((t) => t.id === d.predecessor_id)
|
|
return predecessor?.title || 'Unknown'
|
|
}).join(', ')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => openDependencyModal(task)}
|
|
style={styles.manageDepsButton}
|
|
>
|
|
Manage Dependencies
|
|
</button>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Dependency Modal */}
|
|
{showDependencyModal && selectedTaskForDependency && (
|
|
<div style={styles.modalOverlay}>
|
|
<div style={styles.modal}>
|
|
<h2 style={styles.modalTitle}>
|
|
Manage Dependencies for "{selectedTaskForDependency.title}"
|
|
</h2>
|
|
|
|
{dependencyError && (
|
|
<div style={styles.modalError}>{dependencyError}</div>
|
|
)}
|
|
|
|
{/* Current Dependencies */}
|
|
<div style={styles.modalSection}>
|
|
<h3 style={styles.modalSectionTitle}>Current Dependencies</h3>
|
|
{getCurrentDependencies().length === 0 ? (
|
|
<p style={styles.modalEmptyText}>No dependencies yet</p>
|
|
) : (
|
|
<ul style={styles.dependencyList}>
|
|
{getCurrentDependencies().map((dep) => {
|
|
const predecessor = tasks.find((t) => t.id === dep.predecessor_id)
|
|
return (
|
|
<li key={dep.id} style={styles.dependencyItem}>
|
|
<span>
|
|
Depends on: <strong>{predecessor?.title || 'Unknown'}</strong>
|
|
<span style={styles.depType}>({dep.dependency_type})</span>
|
|
</span>
|
|
<button
|
|
onClick={() => handleRemoveDependency(dep.id)}
|
|
style={styles.removeDependencyButton}
|
|
>
|
|
Remove
|
|
</button>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add New Dependency */}
|
|
<div style={styles.modalSection}>
|
|
<h3 style={styles.modalSectionTitle}>Add Dependency</h3>
|
|
{getAvailablePredecessors().length === 0 ? (
|
|
<p style={styles.modalEmptyText}>No available tasks to add as dependencies</p>
|
|
) : (
|
|
<div style={styles.addDependencyForm}>
|
|
<select
|
|
value={selectedPredecessor}
|
|
onChange={(e) => setSelectedPredecessor(e.target.value)}
|
|
style={styles.modalSelect}
|
|
>
|
|
<option value="">Select a task...</option>
|
|
{getAvailablePredecessors().map((task) => (
|
|
<option key={task.id} value={task.id}>
|
|
{task.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
value={selectedDependencyType}
|
|
onChange={(e) => setSelectedDependencyType(e.target.value as DependencyType)}
|
|
style={styles.dependencyTypeSelect}
|
|
>
|
|
<option value="FS">Finish-to-Start (FS)</option>
|
|
<option value="SS">Start-to-Start (SS)</option>
|
|
<option value="FF">Finish-to-Finish (FF)</option>
|
|
<option value="SF">Start-to-Finish (SF)</option>
|
|
</select>
|
|
<button
|
|
onClick={handleAddDependency}
|
|
disabled={!selectedPredecessor}
|
|
style={{
|
|
...styles.addDependencyButton,
|
|
...(selectedPredecessor ? {} : styles.buttonDisabled),
|
|
}}
|
|
>
|
|
Add Dependency
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={styles.modalActions}>
|
|
<button
|
|
onClick={() => {
|
|
setShowDependencyModal(false)
|
|
setSelectedTaskForDependency(null)
|
|
setDependencyError(null)
|
|
setSelectedDependencyType('FS')
|
|
}}
|
|
style={styles.closeButton}
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Custom CSS for Gantt */}
|
|
<style>{ganttStyles}</style>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Custom CSS styles for Gantt (including base frappe-gantt styles)
|
|
const ganttStyles = `
|
|
/* Base Frappe Gantt CSS */
|
|
:root{--g-arrow-color: #1f2937;--g-bar-color: #fff;--g-bar-border: #fff;--g-tick-color-thick: #ededed;--g-tick-color: #f3f3f3;--g-actions-background: #f3f3f3;--g-border-color: #ebeff2;--g-text-muted: #7c7c7c;--g-text-light: #fff;--g-text-dark: #171717;--g-progress-color: #dbdbdb;--g-handle-color: #37352f;--g-weekend-label-color: #dcdce4;--g-expected-progress: #c4c4e9;--g-header-background: #fff;--g-row-color: #fdfdfd;--g-row-border-color: #c7c7c7;--g-today-highlight: #37352f;--g-popup-actions: #ebeff2;--g-weekend-highlight-color: #f7f7f7}.gantt-container{line-height:14.5px;position:relative;overflow:auto;font-size:12px;height:var(--gv-grid-height);width:100%;border-radius:8px}.gantt-container .popup-wrapper{position:absolute;top:0;left:0;background:#fff;box-shadow:0 10px 24px -3px #0003;padding:10px;border-radius:5px;width:max-content;z-index:1000}.gantt-container .popup-wrapper .title{margin-bottom:2px;color:var(--g-text-dark);font-size:.85rem;font-weight:650;line-height:15px}.gantt-container .popup-wrapper .subtitle{color:var(--g-text-dark);font-size:.8rem;margin-bottom:5px}.gantt-container .popup-wrapper .details{color:var(--g-text-muted);font-size:.7rem}.gantt-container .popup-wrapper .actions{margin-top:10px;margin-left:3px}.gantt-container .popup-wrapper .action-btn{border:none;padding:5px 8px;background-color:var(--g-popup-actions);border-right:1px solid var(--g-text-light)}.gantt-container .popup-wrapper .action-btn:hover{background-color:brightness(97%)}.gantt-container .popup-wrapper .action-btn:first-child{border-top-left-radius:4px;border-bottom-left-radius:4px}.gantt-container .popup-wrapper .action-btn:last-child{border-right:none;border-top-right-radius:4px;border-bottom-right-radius:4px}.gantt-container .grid-header{height:calc(var(--gv-lower-header-height) + var(--gv-upper-header-height) + 10px);background-color:var(--g-header-background);position:sticky;top:0;left:0;border-bottom:1px solid var(--g-row-border-color);z-index:1000}.gantt-container .lower-text,.gantt-container .upper-text{text-anchor:middle}.gantt-container .upper-header{height:var(--gv-upper-header-height)}.gantt-container .lower-header{height:var(--gv-lower-header-height)}.gantt-container .lower-text{font-size:12px;position:absolute;width:calc(var(--gv-column-width) * .8);height:calc(var(--gv-lower-header-height) * .8);margin:0 calc(var(--gv-column-width) * .1);align-content:center;text-align:center;color:var(--g-text-muted)}.gantt-container .upper-text{position:absolute;width:fit-content;font-weight:500;font-size:14px;color:var(--g-text-dark);height:calc(var(--gv-lower-header-height) * .66)}.gantt-container .current-upper{position:sticky;left:0!important;padding-left:17px;background:#fff}.gantt-container .side-header{position:sticky;top:0;right:0;float:right;z-index:1000;line-height:20px;font-weight:400;width:max-content;margin-left:auto;padding-right:10px;padding-top:10px;background:var(--g-header-background);display:flex}.gantt-container .side-header *{transition-property:background-color;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;background-color:var(--g-actions-background);border-radius:.5rem;border:none;padding:5px 8px;color:var(--g-text-dark);font-size:14px;letter-spacing:.02em;font-weight:420;box-sizing:content-box;margin-right:5px}.gantt-container .side-header *:last-child{margin-right:0}.gantt-container .side-header *:hover{filter:brightness(97.5%)}.gantt-container .side-header select{width:60px;padding-top:2px;padding-bottom:2px}.gantt-container .side-header select:focus{outline:none}.gantt-container .date-range-highlight{background-color:var(--g-progress-color);border-radius:12px;height:calc(var(--gv-lower-header-height) - 6px);top:calc(var(--gv-upper-header-height) + 5px);position:absolute}.gantt-container .current-highlight{position:absolute;background:var(--g-today-highlight);width:1px;z-index:999}.gantt-container .current-ball-highlight{position:absolute;background:var(--g-today-highlight);z-index:1001;border-radius:50%}.gantt-container .current-date-highlight{background:var(--g-today-highlight);color:var(--g-text-light);border-radius:5px}.gantt-container .holiday-label{position:absolute;top:0;left:0;opacity:0;z-index:1000;background:--g-weekend-label-color;border-radius:5px;padding:2px 5px}.gantt-container .holiday-label.show{opacity:100}.gantt-container .extras{position:sticky;left:0}.gantt-container .extras .adjust{position:absolute;left:8px;top:calc(var(--gv-grid-height) - 60px);background-color:#000000b3;color:#fff;border:none;padding:8px;border-radius:3px}.gantt-container .hide{display:none}.gantt{user-select:none;-webkit-user-select:none;position:absolute}.gantt .grid-background{fill:none}.gantt .grid-row{fill:var(--g-row-color)}.gantt .row-line{stroke:var(--g-border-color)}.gantt .tick{stroke:var(--g-tick-color);stroke-width:.4}.gantt .tick.thick{stroke:var(--g-tick-color-thick);stroke-width:.7}.gantt .arrow{fill:none;stroke:var(--g-arrow-color);stroke-width:1.5}.gantt .bar-wrapper .bar{fill:var(--g-bar-color);stroke:var(--g-bar-border);stroke-width:0;transition:stroke-width .3s ease}.gantt .bar-progress{fill:var(--g-progress-color);border-radius:4px}.gantt .bar-expected-progress{fill:var(--g-expected-progress)}.gantt .bar-invalid{fill:transparent;stroke:var(--g-bar-border);stroke-width:1;stroke-dasharray:5}:is(.gantt .bar-invalid)~.bar-label{fill:var(--g-text-light)}.gantt .bar-label{fill:var(--g-text-dark);dominant-baseline:central;font-family:Helvetica;font-size:13px;font-weight:400}.gantt .bar-label.big{fill:var(--g-text-dark);text-anchor:start}.gantt .handle{fill:var(--g-handle-color);opacity:0;transition:opacity .3s ease}.gantt .handle.active,.gantt .handle.visible{cursor:ew-resize;opacity:1}.gantt .handle.progress{fill:var(--g-text-muted)}.gantt .bar-wrapper{cursor:pointer}.gantt .bar-wrapper .bar{outline:1px solid var(--g-row-border-color);border-radius:3px}.gantt .bar-wrapper:hover .bar{transition:transform .3s ease}.gantt .bar-wrapper:hover .date-range-highlight{display:block}
|
|
|
|
/* Override Frappe Gantt styles to match app theme */
|
|
.gantt .bar-wrapper .bar {
|
|
fill: #0066cc;
|
|
stroke: #0066cc;
|
|
stroke-width: 0;
|
|
}
|
|
|
|
.gantt .bar-wrapper .bar-progress {
|
|
fill: #004c99;
|
|
}
|
|
|
|
.gantt .bar-wrapper .bar-label {
|
|
fill: white;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* Priority-based bar colors */
|
|
.gantt .bar-wrapper.gantt-bar-urgent .bar {
|
|
fill: #f44336;
|
|
stroke: #d32f2f;
|
|
}
|
|
.gantt .bar-wrapper.gantt-bar-urgent .bar-progress {
|
|
fill: #c62828;
|
|
}
|
|
|
|
.gantt .bar-wrapper.gantt-bar-high .bar {
|
|
fill: #ff9800;
|
|
stroke: #f57c00;
|
|
}
|
|
.gantt .bar-wrapper.gantt-bar-high .bar-progress {
|
|
fill: #ef6c00;
|
|
}
|
|
|
|
.gantt .bar-wrapper.gantt-bar-medium .bar {
|
|
fill: #0066cc;
|
|
stroke: #0052a3;
|
|
}
|
|
.gantt .bar-wrapper.gantt-bar-medium .bar-progress {
|
|
fill: #004080;
|
|
}
|
|
|
|
.gantt .bar-wrapper.gantt-bar-low .bar {
|
|
fill: #808080;
|
|
stroke: #666666;
|
|
}
|
|
.gantt .bar-wrapper.gantt-bar-low .bar-progress {
|
|
fill: #5a5a5a;
|
|
}
|
|
|
|
/* Arrow (dependency) styling */
|
|
.gantt .arrow {
|
|
stroke: #666;
|
|
stroke-width: 2;
|
|
}
|
|
|
|
/* Grid styling */
|
|
.gantt .grid-row {
|
|
fill: transparent;
|
|
}
|
|
.gantt .grid-row:nth-child(even) {
|
|
fill: #f9f9f9;
|
|
}
|
|
|
|
.gantt .row-line {
|
|
stroke: #e0e0e0;
|
|
}
|
|
|
|
.gantt .tick {
|
|
stroke: #e0e0e0;
|
|
}
|
|
|
|
.gantt .today-highlight {
|
|
fill: rgba(0, 102, 204, 0.1);
|
|
}
|
|
|
|
/* Header styling */
|
|
.gantt .lower-text, .gantt .upper-text {
|
|
fill: #333;
|
|
font-size: 12px;
|
|
}
|
|
|
|
/* Popup styling */
|
|
.gantt-popup {
|
|
padding: 10px;
|
|
min-width: 180px;
|
|
}
|
|
|
|
.gantt-popup-title {
|
|
margin: 0 0 8px 0;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
color: #333;
|
|
}
|
|
|
|
.gantt-popup-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.gantt-popup-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.gantt-popup-label {
|
|
color: #666;
|
|
min-width: 60px;
|
|
}
|
|
|
|
.gantt-popup-value {
|
|
color: #333;
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* Popup container override */
|
|
.gantt .popup-wrapper {
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
border: 1px solid #e0e0e0;
|
|
}
|
|
`
|
|
|
|
const styles: Record<string, React.CSSProperties> = {
|
|
container: {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '16px',
|
|
},
|
|
toolbar: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
padding: '12px 16px',
|
|
backgroundColor: '#f9f9f9',
|
|
borderRadius: '8px',
|
|
border: '1px solid #eee',
|
|
},
|
|
viewModeButtons: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
},
|
|
toolbarLabel: {
|
|
fontSize: '14px',
|
|
fontWeight: 500,
|
|
color: '#666',
|
|
marginRight: '4px',
|
|
},
|
|
viewModeButton: {
|
|
padding: '6px 16px',
|
|
backgroundColor: 'white',
|
|
border: '1px solid #ddd',
|
|
borderRadius: '4px',
|
|
cursor: 'pointer',
|
|
fontSize: '13px',
|
|
color: '#666',
|
|
transition: 'all 0.2s',
|
|
},
|
|
viewModeButtonActive: {
|
|
backgroundColor: '#0066cc',
|
|
borderColor: '#0066cc',
|
|
color: 'white',
|
|
},
|
|
loadingIndicator: {
|
|
fontSize: '13px',
|
|
color: '#666',
|
|
},
|
|
errorIndicator: {
|
|
fontSize: '13px',
|
|
color: '#f44336',
|
|
backgroundColor: '#ffebee',
|
|
padding: '4px 12px',
|
|
borderRadius: '4px',
|
|
},
|
|
ganttWrapper: {
|
|
backgroundColor: 'white',
|
|
borderRadius: '8px',
|
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
|
overflow: 'auto',
|
|
},
|
|
ganttContainer: {
|
|
minHeight: '300px',
|
|
},
|
|
emptyState: {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
padding: '64px 24px',
|
|
backgroundColor: 'white',
|
|
borderRadius: '8px',
|
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
|
},
|
|
emptyText: {
|
|
fontSize: '16px',
|
|
color: '#333',
|
|
margin: '0 0 8px 0',
|
|
},
|
|
emptyHint: {
|
|
fontSize: '14px',
|
|
color: '#666',
|
|
margin: 0,
|
|
},
|
|
footer: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
padding: '12px 16px',
|
|
backgroundColor: '#f9f9f9',
|
|
borderRadius: '8px',
|
|
fontSize: '13px',
|
|
},
|
|
legend: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '16px',
|
|
},
|
|
legendTitle: {
|
|
color: '#666',
|
|
fontWeight: 500,
|
|
},
|
|
legendItem: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
color: '#666',
|
|
},
|
|
legendDot: {
|
|
width: '12px',
|
|
height: '12px',
|
|
borderRadius: '3px',
|
|
},
|
|
footerActions: {},
|
|
footerHint: {
|
|
color: '#888',
|
|
fontStyle: 'italic',
|
|
},
|
|
dependenciesSection: {
|
|
padding: '16px',
|
|
backgroundColor: 'white',
|
|
borderRadius: '8px',
|
|
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
|
},
|
|
dependenciesTitle: {
|
|
margin: '0 0 8px 0',
|
|
fontSize: '16px',
|
|
fontWeight: 600,
|
|
color: '#333',
|
|
},
|
|
dependenciesHint: {
|
|
margin: '0 0 16px 0',
|
|
fontSize: '13px',
|
|
color: '#666',
|
|
},
|
|
taskList: {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '8px',
|
|
},
|
|
taskItem: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
padding: '12px',
|
|
backgroundColor: '#f9f9f9',
|
|
borderRadius: '6px',
|
|
border: '1px solid #eee',
|
|
},
|
|
taskItemInfo: {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '4px',
|
|
},
|
|
taskItemTitle: {
|
|
fontSize: '14px',
|
|
fontWeight: 500,
|
|
color: '#333',
|
|
},
|
|
taskItemDeps: {
|
|
fontSize: '12px',
|
|
color: '#666',
|
|
},
|
|
manageDepsButton: {
|
|
padding: '6px 12px',
|
|
backgroundColor: 'white',
|
|
border: '1px solid #ddd',
|
|
borderRadius: '4px',
|
|
cursor: 'pointer',
|
|
fontSize: '12px',
|
|
color: '#666',
|
|
},
|
|
modalOverlay: {
|
|
position: 'fixed',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
zIndex: 1000,
|
|
},
|
|
modal: {
|
|
backgroundColor: 'white',
|
|
padding: '24px',
|
|
borderRadius: '8px',
|
|
width: '500px',
|
|
maxWidth: '90%',
|
|
maxHeight: '80vh',
|
|
overflowY: 'auto',
|
|
},
|
|
modalTitle: {
|
|
margin: '0 0 16px 0',
|
|
fontSize: '18px',
|
|
fontWeight: 600,
|
|
color: '#333',
|
|
},
|
|
modalError: {
|
|
padding: '10px 14px',
|
|
backgroundColor: '#ffebee',
|
|
color: '#c62828',
|
|
borderRadius: '4px',
|
|
fontSize: '13px',
|
|
marginBottom: '16px',
|
|
},
|
|
modalSection: {
|
|
marginBottom: '20px',
|
|
},
|
|
modalSectionTitle: {
|
|
margin: '0 0 10px 0',
|
|
fontSize: '14px',
|
|
fontWeight: 500,
|
|
color: '#333',
|
|
},
|
|
modalEmptyText: {
|
|
fontSize: '13px',
|
|
color: '#666',
|
|
fontStyle: 'italic',
|
|
},
|
|
dependencyList: {
|
|
margin: 0,
|
|
padding: 0,
|
|
listStyle: 'none',
|
|
},
|
|
dependencyItem: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
padding: '10px 12px',
|
|
backgroundColor: '#f5f5f5',
|
|
borderRadius: '4px',
|
|
marginBottom: '8px',
|
|
fontSize: '13px',
|
|
},
|
|
depType: {
|
|
color: '#888',
|
|
marginLeft: '6px',
|
|
fontSize: '12px',
|
|
},
|
|
removeDependencyButton: {
|
|
padding: '4px 10px',
|
|
backgroundColor: '#ffebee',
|
|
border: 'none',
|
|
borderRadius: '4px',
|
|
cursor: 'pointer',
|
|
fontSize: '12px',
|
|
color: '#c62828',
|
|
},
|
|
addDependencyForm: {
|
|
display: 'flex',
|
|
gap: '12px',
|
|
},
|
|
modalSelect: {
|
|
flex: 1,
|
|
padding: '10px',
|
|
border: '1px solid #ddd',
|
|
borderRadius: '4px',
|
|
fontSize: '14px',
|
|
},
|
|
dependencyTypeSelect: {
|
|
padding: '10px',
|
|
border: '1px solid #ddd',
|
|
borderRadius: '4px',
|
|
fontSize: '14px',
|
|
minWidth: '180px',
|
|
},
|
|
addDependencyButton: {
|
|
padding: '10px 20px',
|
|
backgroundColor: '#0066cc',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: '4px',
|
|
cursor: 'pointer',
|
|
fontSize: '14px',
|
|
},
|
|
buttonDisabled: {
|
|
backgroundColor: '#ccc',
|
|
cursor: 'not-allowed',
|
|
},
|
|
modalActions: {
|
|
display: 'flex',
|
|
justifyContent: 'flex-end',
|
|
marginTop: '24px',
|
|
},
|
|
closeButton: {
|
|
padding: '10px 24px',
|
|
backgroundColor: '#f5f5f5',
|
|
border: '1px solid #ddd',
|
|
borderRadius: '4px',
|
|
cursor: 'pointer',
|
|
fontSize: '14px',
|
|
},
|
|
}
|
|
|
|
export default GanttChart
|