Files
PROJECT-CONTORL/frontend/src/components/GanttChart.tsx
beabigegg 2d80a8384e feat: implement custom fields, gantt view, calendar view, and file encryption
- 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>
2026-01-05 23:39:12 +08:00

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