1261 lines
37 KiB
Vue
1261 lines
37 KiB
Vue
<script setup>
|
|
import { ref, onMounted, onUnmounted, nextTick, computed, watch } from 'vue'
|
|
import { gantt } from 'dhtmlx-gantt'
|
|
import 'dhtmlx-gantt/codebase/dhtmlxgantt.css'
|
|
|
|
// Reactive data
|
|
const ganttContainer = ref(null)
|
|
const isLoading = ref(true)
|
|
const selectedTask = ref(null)
|
|
const showTaskDetails = ref(false)
|
|
const showResourcePanel = ref(false)
|
|
const showFilterPanel = ref(false)
|
|
const currentView = ref('month')
|
|
const searchQuery = ref('')
|
|
const snackbar = ref(false)
|
|
const snackbarText = ref('')
|
|
|
|
// Sample data with proper date format
|
|
const projectData = ref({
|
|
tasks: {
|
|
data: [
|
|
{ id: 1, text: "Software Development Project", start_date: "01-01-2024", duration: 30, progress: 0.6, open: true, priority: "high", resource: "team1" },
|
|
{ id: 2, text: "Requirements Analysis", start_date: "01-01-2024", duration: 5, progress: 0.4, parent: 1, priority: "medium", resource: "analyst1" },
|
|
{ id: 3, text: "Architecture Design", start_date: "06-01-2024", duration: 7, progress: 0.6, parent: 1, priority: "high", resource: "architect1" },
|
|
{ id: 4, text: "Frontend Development", start_date: "13-01-2024", duration: 10, progress: 0.8, parent: 1, priority: "medium", resource: "developer1" },
|
|
{ id: 5, text: "Backend Development", start_date: "13-01-2024", duration: 12, progress: 0.7, parent: 1, priority: "medium", resource: "developer2" },
|
|
{ id: 6, text: "Testing & Debugging", start_date: "25-01-2024", duration: 5, progress: 0.3, parent: 1, priority: "low", resource: "tester1" },
|
|
{ id: 7, text: "Documentation", start_date: "30-01-2024", duration: 3, progress: 0.1, parent: 1, priority: "low", resource: "writer1" },
|
|
{ id: 8, text: "Marketing Campaign", start_date: "15-01-2024", duration: 20, progress: 0.5, open: true, priority: "medium", resource: "team2" },
|
|
{ id: 9, text: "Market Research", start_date: "15-01-2024", duration: 5, progress: 0.8, parent: 8, priority: "high", resource: "researcher1" },
|
|
{ id: 10, text: "Campaign Design", start_date: "20-01-2024", duration: 7, progress: 0.6, parent: 8, priority: "medium", resource: "designer1" },
|
|
{ id: 11, text: "Campaign Execution", start_date: "27-01-2024", duration: 8, progress: 0.2, parent: 8, priority: "medium", resource: "marketer1" }
|
|
],
|
|
links: [
|
|
{ id: 1, source: 2, target: 3, type: "0" },
|
|
{ id: 2, source: 3, target: 4, type: "0" },
|
|
{ id: 3, source: 3, target: 5, type: "0" },
|
|
{ id: 4, source: 4, target: 6, type: "0" },
|
|
{ id: 5, source: 5, target: 6, type: "0" },
|
|
{ id: 6, source: 6, target: 7, type: "0" },
|
|
{ id: 7, source: 9, target: 10, type: "0" },
|
|
{ id: 8, source: 10, target: 11, type: "0" }
|
|
]
|
|
},
|
|
resources: [
|
|
{ id: "team1", name: "Development Team 1", capacity: 100 },
|
|
{ id: "team2", name: "Marketing Team", capacity: 80 },
|
|
{ id: "analyst1", name: "Senior Analyst", capacity: 100 },
|
|
{ id: "architect1", name: "Software Architect", capacity: 100 },
|
|
{ id: "developer1", name: "Frontend Developer", capacity: 100 },
|
|
{ id: "developer2", name: "Backend Developer", capacity: 100 },
|
|
{ id: "tester1", name: "QA Tester", capacity: 100 },
|
|
{ id: "writer1", name: "Technical Writer", capacity: 100 },
|
|
{ id: "researcher1", name: "Market Researcher", capacity: 100 },
|
|
{ id: "designer1", name: "Graphic Designer", capacity: 100 },
|
|
{ id: "marketer1", name: "Marketing Specialist", capacity: 100 }
|
|
]
|
|
})
|
|
|
|
// Computed properties
|
|
const filteredTasks = computed(() => {
|
|
if (!searchQuery.value) return projectData.value.tasks.data
|
|
|
|
return projectData.value.tasks.data.filter(task =>
|
|
task.text.toLowerCase().includes(searchQuery.value.toLowerCase())
|
|
)
|
|
})
|
|
|
|
const priorityOptions = [
|
|
{ value: 'high', label: 'High', color: 'error', icon: 'tabler-arrow-up' },
|
|
{ value: 'medium', label: 'Medium', color: 'warning', icon: 'tabler-arrow-right' },
|
|
{ value: 'low', label: 'Low', color: 'success', icon: 'tabler-arrow-down' }
|
|
]
|
|
|
|
const viewOptions = [
|
|
{ value: 'day', label: 'Daily', icon: 'tabler-calendar' },
|
|
{ value: 'week', label: 'Weekly', icon: 'tabler-calendar-week' },
|
|
{ value: 'month', label: 'Monthly', icon: 'tabler-calendar-month' },
|
|
{ value: 'quarter', label: 'Quarterly', icon: 'tabler-calendar-time' },
|
|
{ value: 'year', label: 'Yearly', icon: 'tabler-calendar-stats' }
|
|
]
|
|
|
|
// Watch for search changes
|
|
watch(searchQuery, (newValue) => {
|
|
performSearch()
|
|
})
|
|
|
|
// Gantt configuration
|
|
const initGantt = () => {
|
|
// Set date format
|
|
gantt.config.date_format = "%d-%m-%Y"
|
|
gantt.config.xml_date = "%d-%m-%Y"
|
|
|
|
// Configure scale
|
|
gantt.config.scale_unit = currentView.value
|
|
gantt.config.date_scale = "%F %Y"
|
|
|
|
// Configure subscales based on view
|
|
updateViewConfig(currentView.value)
|
|
|
|
// Enable features
|
|
gantt.config.drag_progress = true
|
|
gantt.config.drag_resize = true
|
|
gantt.config.drag_move = true
|
|
gantt.config.drag_links = true
|
|
gantt.config.show_links = true
|
|
gantt.config.show_progress = true
|
|
gantt.config.show_chart = true
|
|
gantt.config.show_grid = true
|
|
gantt.config.auto_scheduling = false
|
|
gantt.config.auto_scheduling_strict = false
|
|
|
|
// Prevent task overlapping issues
|
|
gantt.config.work_time = true
|
|
gantt.config.correct_work_time = true
|
|
|
|
// Custom columns
|
|
gantt.config.columns = [
|
|
{ name: "text", label: "Task Name", width: 200, tree: true },
|
|
{
|
|
name: "start_date",
|
|
label: "Start Date",
|
|
align: "center",
|
|
width: 100,
|
|
template: function(obj) {
|
|
if (!obj.start_date) return ""
|
|
try {
|
|
return gantt.date.date_to_str("%d/%m/%Y")(gantt.date.str_to_date("%d-%m-%Y")(obj.start_date))
|
|
} catch (e) {
|
|
return obj.start_date
|
|
}
|
|
}
|
|
},
|
|
{ name: "duration", label: "Duration (Days)", align: "center", width: 80 },
|
|
{
|
|
name: "progress",
|
|
label: "Progress",
|
|
align: "center",
|
|
width: 100,
|
|
template: function(obj) {
|
|
return Math.round((obj.progress || 0) * 100) + "%"
|
|
}
|
|
},
|
|
{
|
|
name: "priority",
|
|
label: "Priority",
|
|
align: "center",
|
|
width: 100,
|
|
template: function(obj) {
|
|
const priority = priorityOptions.find(p => p.value === obj.priority)
|
|
if (!priority) return obj.priority || ''
|
|
return `<span style="color: var(--v-theme-${priority.color}); font-weight: bold;">${priority.label}</span>`
|
|
}
|
|
},
|
|
{
|
|
name: "resource",
|
|
label: "Resource",
|
|
align: "center",
|
|
width: 120,
|
|
template: function(obj) {
|
|
const resource = projectData.value.resources.find(r => r.id === obj.resource)
|
|
return resource ? resource.name : (obj.resource || '')
|
|
}
|
|
}
|
|
]
|
|
|
|
// Templates
|
|
gantt.templates.task_text = function(start, end, task) {
|
|
return `<span title='${task.text || ""}'>${task.text || ""}</span>`
|
|
}
|
|
|
|
gantt.templates.progress_text = function(start, end, task) {
|
|
return Math.round((task.progress || 0) * 100) + "%"
|
|
}
|
|
|
|
gantt.templates.task_class = function(start, end, task) {
|
|
let classes = []
|
|
if (task.priority === 'high') classes.push('high-priority')
|
|
if (task.priority === 'medium') classes.push('medium-priority')
|
|
if (task.priority === 'low') classes.push('low-priority')
|
|
classes.push('gantt-task-rounded')
|
|
return classes.join(' ')
|
|
}
|
|
|
|
gantt.templates.tooltip_text = function(start, end, task) {
|
|
const resource = projectData.value.resources.find(r => r.id === task.resource)
|
|
const startDate = task.start_date || "Not set"
|
|
const duration = task.duration || 0
|
|
const progress = Math.round((task.progress || 0) * 100)
|
|
const priority = task.priority || "Not set"
|
|
const resourceName = resource ? resource.name : (task.resource || "Not assigned")
|
|
|
|
return `<b>${task.text || "Unnamed Task"}</b><br/>
|
|
Start: ${startDate}<br/>
|
|
Duration: ${duration} days<br/>
|
|
Progress: ${progress}%<br/>
|
|
Priority: ${priority}<br/>
|
|
Resource: ${resourceName}`
|
|
}
|
|
|
|
// Events
|
|
gantt.attachEvent("onTaskClick", function(id, e) {
|
|
try {
|
|
const task = gantt.getTask(id)
|
|
if (task) {
|
|
selectedTask.value = { ...task } // Create a copy to avoid direct mutation
|
|
showTaskDetails.value = true
|
|
}
|
|
} catch (error) {
|
|
console.error("Error selecting task:", error)
|
|
showSnackbar('Error selecting task', 'error')
|
|
}
|
|
return true
|
|
})
|
|
|
|
gantt.attachEvent("onAfterTaskUpdate", function(id, task) {
|
|
showSnackbar('Task updated successfully!')
|
|
})
|
|
|
|
gantt.attachEvent("onAfterLinkAdd", function(id, link) {
|
|
showSnackbar('Link added!')
|
|
})
|
|
|
|
gantt.attachEvent("onAfterTaskAdd", function(id, task) {
|
|
showSnackbar('Task added!')
|
|
})
|
|
|
|
gantt.attachEvent("onAfterTaskDelete", function(id) {
|
|
showSnackbar('Task deleted!')
|
|
if (selectedTask.value && selectedTask.value.id === id) {
|
|
selectedTask.value = null
|
|
showTaskDetails.value = false
|
|
}
|
|
})
|
|
|
|
// Error handling
|
|
gantt.attachEvent("onTaskDragEnd", function(id, mode, e) {
|
|
try {
|
|
gantt.updateTask(id)
|
|
return true
|
|
} catch (error) {
|
|
console.error("Error updating task:", error)
|
|
showSnackbar('Error updating task', 'error')
|
|
return false
|
|
}
|
|
})
|
|
|
|
// Initialize
|
|
try {
|
|
gantt.init(ganttContainer.value)
|
|
gantt.parse(projectData.value)
|
|
isLoading.value = false
|
|
} catch (error) {
|
|
console.error("Error initializing Gantt:", error)
|
|
showSnackbar('Error initializing Gantt chart', 'error')
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
// Helper function for view configuration
|
|
const updateViewConfig = (view) => {
|
|
gantt.config.scale_unit = view
|
|
|
|
// Clear existing subscales
|
|
gantt.config.subscales = []
|
|
|
|
// Configure subscales based on view
|
|
switch (view) {
|
|
case 'day':
|
|
gantt.config.date_scale = "%d %F %Y"
|
|
gantt.config.subscales = [
|
|
{ unit: "hour", step: 6, date: "%H:00" }
|
|
]
|
|
break
|
|
case 'week':
|
|
gantt.config.date_scale = "Week %W, %F %Y"
|
|
gantt.config.subscales = [
|
|
{ unit: "day", step: 1, date: "%d %M" }
|
|
]
|
|
break
|
|
case 'month':
|
|
gantt.config.date_scale = "%F %Y"
|
|
gantt.config.subscales = [
|
|
{ unit: "week", step: 1, date: "Week %W" }
|
|
]
|
|
break
|
|
case 'quarter':
|
|
gantt.config.date_scale = "%Y"
|
|
gantt.config.subscales = [
|
|
{ unit: "month", step: 1, date: "%F" }
|
|
]
|
|
break
|
|
case 'year':
|
|
gantt.config.date_scale = "%Y"
|
|
gantt.config.subscales = [
|
|
{ unit: "quarter", step: 1, date: "Q%q" }
|
|
]
|
|
break
|
|
}
|
|
}
|
|
|
|
// Helper function for snackbar
|
|
const showSnackbar = (message, type = 'success') => {
|
|
snackbarText.value = message
|
|
snackbar.value = true
|
|
}
|
|
|
|
// Task management functions
|
|
const addTask = () => {
|
|
try {
|
|
const today = new Date()
|
|
const formattedDate = gantt.date.date_to_str("%d-%m-%Y")(today)
|
|
|
|
const newTask = {
|
|
id: gantt.uid(),
|
|
text: "New Task",
|
|
start_date: formattedDate,
|
|
duration: 1,
|
|
progress: 0,
|
|
priority: "medium",
|
|
resource: projectData.value.resources[0]?.id || ""
|
|
}
|
|
|
|
gantt.addTask(newTask)
|
|
} catch (error) {
|
|
console.error("Error adding task:", error)
|
|
showSnackbar('Error adding task', 'error')
|
|
}
|
|
}
|
|
|
|
const deleteTask = () => {
|
|
if (selectedTask.value) {
|
|
try {
|
|
gantt.deleteTask(selectedTask.value.id)
|
|
selectedTask.value = null
|
|
showTaskDetails.value = false
|
|
} catch (error) {
|
|
console.error("Error deleting task:", error)
|
|
showSnackbar('Error deleting task', 'error')
|
|
}
|
|
}
|
|
}
|
|
|
|
const updateTask = (task) => {
|
|
try {
|
|
if (!task || !task.id) {
|
|
throw new Error("Invalid task data")
|
|
}
|
|
|
|
// Validate required fields
|
|
if (!task.text || task.text.trim() === '') {
|
|
task.text = "Unnamed Task"
|
|
}
|
|
|
|
if (!task.start_date) {
|
|
const today = new Date()
|
|
task.start_date = gantt.date.date_to_str("%d-%m-%Y")(today)
|
|
}
|
|
|
|
if (!task.duration || task.duration < 1) {
|
|
task.duration = 1
|
|
}
|
|
|
|
if (task.progress < 0) task.progress = 0
|
|
if (task.progress > 1) task.progress = 1
|
|
|
|
gantt.updateTask(task.id, task)
|
|
|
|
// Update selectedTask to reflect changes
|
|
selectedTask.value = { ...task }
|
|
showTaskDetails.value = false
|
|
showSnackbar('Task updated successfully!')
|
|
} catch (error) {
|
|
console.error("Error updating task:", error)
|
|
showSnackbar('Error updating task', 'error')
|
|
}
|
|
}
|
|
|
|
// View management
|
|
const changeView = (view) => {
|
|
try {
|
|
currentView.value = view
|
|
updateViewConfig(view)
|
|
gantt.render()
|
|
showSnackbar(`View changed to ${view}`)
|
|
} catch (error) {
|
|
console.error("Error changing view:", error)
|
|
showSnackbar('Error changing view', 'error')
|
|
}
|
|
}
|
|
|
|
// Export functions with error handling
|
|
const exportToPDF = () => {
|
|
try {
|
|
if (gantt.exportToPDF) {
|
|
gantt.exportToPDF({
|
|
name: "project-gantt.pdf",
|
|
header: "<h1>Project Gantt Chart</h1>",
|
|
footer: "<div>Generated on " + new Date().toLocaleDateString('en-US') + "</div>"
|
|
})
|
|
showSnackbar('PDF export started')
|
|
} else {
|
|
showSnackbar('PDF export not available', 'error')
|
|
}
|
|
} catch (error) {
|
|
console.error("Error exporting to PDF:", error)
|
|
showSnackbar('Error exporting to PDF', 'error')
|
|
}
|
|
}
|
|
|
|
const exportToPNG = () => {
|
|
try {
|
|
if (gantt.exportToPNG) {
|
|
gantt.exportToPNG({
|
|
name: "project-gantt.png"
|
|
})
|
|
showSnackbar('PNG export started')
|
|
} else {
|
|
showSnackbar('PNG export not available', 'error')
|
|
}
|
|
} catch (error) {
|
|
console.error("Error exporting to PNG:", error)
|
|
showSnackbar('Error exporting to PNG', 'error')
|
|
}
|
|
}
|
|
|
|
const exportToExcel = () => {
|
|
try {
|
|
const data = gantt.getTaskByTime()
|
|
// Create a simple CSV export as fallback
|
|
const csvContent = "data:text/csv;charset=utf-8," +
|
|
"Task Name,Start Date,Duration,Progress,Priority,Resource\n" +
|
|
data.map(task => {
|
|
const resource = projectData.value.resources.find(r => r.id === task.resource)
|
|
return `"${task.text}","${task.start_date}","${task.duration}","${Math.round((task.progress || 0) * 100)}%","${task.priority}","${resource ? resource.name : task.resource || ''}"`
|
|
}).join("\n")
|
|
|
|
const encodedUri = encodeURI(csvContent)
|
|
const link = document.createElement("a")
|
|
link.setAttribute("href", encodedUri)
|
|
link.setAttribute("download", "project-gantt.csv")
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
document.body.removeChild(link)
|
|
|
|
showSnackbar('Excel/CSV export completed')
|
|
} catch (error) {
|
|
console.error("Error exporting to Excel:", error)
|
|
showSnackbar('Error exporting to Excel', 'error')
|
|
}
|
|
}
|
|
|
|
// Search functionality with error handling
|
|
const performSearch = () => {
|
|
try {
|
|
if (!gantt.getTaskByTime) {
|
|
console.warn("Gantt not fully initialized for search")
|
|
return
|
|
}
|
|
|
|
if (searchQuery.value && searchQuery.value.trim() !== '') {
|
|
const tasks = gantt.getTaskByTime()
|
|
const filtered = tasks.filter(task =>
|
|
task.text && task.text.toLowerCase().includes(searchQuery.value.toLowerCase())
|
|
)
|
|
|
|
// Clear previous highlights
|
|
gantt.eachTask(function(task) {
|
|
gantt.removeTaskClass(task.id, "search-highlight")
|
|
})
|
|
|
|
// Highlight filtered tasks
|
|
filtered.forEach(task => {
|
|
gantt.addTaskClass(task.id, "search-highlight")
|
|
})
|
|
|
|
if (filtered.length === 0) {
|
|
showSnackbar('No tasks found matching search criteria', 'warning')
|
|
}
|
|
} else {
|
|
// Clear all highlights when search is empty
|
|
gantt.eachTask(function(task) {
|
|
gantt.removeTaskClass(task.id, "search-highlight")
|
|
})
|
|
}
|
|
} catch (error) {
|
|
console.error("Error performing search:", error)
|
|
showSnackbar('Error performing search', 'error')
|
|
}
|
|
}
|
|
|
|
// Close task details when clicking outside
|
|
const closeTaskDetails = () => {
|
|
showTaskDetails.value = false
|
|
selectedTask.value = null
|
|
}
|
|
|
|
// Resource utilization calculation
|
|
const getResourceUtilization = (resourceId) => {
|
|
try {
|
|
const tasksForResource = projectData.value.tasks.data.filter(task => task.resource === resourceId)
|
|
if (tasksForResource.length === 0) return 0
|
|
|
|
const totalProgress = tasksForResource.reduce((sum, task) => sum + (task.progress || 0), 0)
|
|
const avgProgress = totalProgress / tasksForResource.length
|
|
return Math.round(avgProgress * 100)
|
|
} catch (error) {
|
|
console.error("Error calculating resource utilization:", error)
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// Filter functionality
|
|
const filterOptions = ref({
|
|
priority: '',
|
|
resource: '',
|
|
progressRange: [0, 100]
|
|
})
|
|
|
|
const applyFilters = () => {
|
|
try {
|
|
const { priority, resource, progressRange } = filterOptions.value
|
|
|
|
gantt.eachTask(function(task) {
|
|
let shouldShow = true
|
|
|
|
// Filter by priority
|
|
if (priority && task.priority !== priority) {
|
|
shouldShow = false
|
|
}
|
|
|
|
// Filter by resource
|
|
if (resource && task.resource !== resource) {
|
|
shouldShow = false
|
|
}
|
|
|
|
// Filter by progress
|
|
const taskProgress = Math.round((task.progress || 0) * 100)
|
|
if (taskProgress < progressRange[0] || taskProgress > progressRange[1]) {
|
|
shouldShow = false
|
|
}
|
|
|
|
// Hide/show task
|
|
if (shouldShow) {
|
|
gantt.showTask(task.id)
|
|
} else {
|
|
gantt.hideTask(task.id)
|
|
}
|
|
})
|
|
|
|
gantt.render()
|
|
showFilterPanel.value = false
|
|
showSnackbar('Filters applied successfully')
|
|
} catch (error) {
|
|
console.error("Error applying filters:", error)
|
|
showSnackbar('Error applying filters', 'error')
|
|
}
|
|
}
|
|
|
|
// Clear all filters
|
|
const clearFilters = () => {
|
|
try {
|
|
filterOptions.value = {
|
|
priority: '',
|
|
resource: '',
|
|
progressRange: [0, 100]
|
|
}
|
|
|
|
gantt.eachTask(function(task) {
|
|
gantt.showTask(task.id)
|
|
})
|
|
|
|
gantt.render()
|
|
showSnackbar('Filters cleared')
|
|
} catch (error) {
|
|
console.error("Error clearing filters:", error)
|
|
showSnackbar('Error clearing filters', 'error')
|
|
}
|
|
}
|
|
|
|
// Lifecycle
|
|
onMounted(async () => {
|
|
try {
|
|
await nextTick()
|
|
if (ganttContainer.value) {
|
|
initGantt()
|
|
} else {
|
|
throw new Error("Gantt container not found")
|
|
}
|
|
} catch (error) {
|
|
console.error("Error mounting Gantt:", error)
|
|
isLoading.value = false
|
|
showSnackbar('Error initializing Gantt chart', 'error')
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
try {
|
|
if (gantt && gantt.clearAll) {
|
|
gantt.clearAll()
|
|
}
|
|
} catch (error) {
|
|
console.error("Error cleaning up Gantt:", error)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<VCard class="gantt-card">
|
|
<VCardTitle class="d-flex align-center justify-space-between">
|
|
<div class="d-flex align-center">
|
|
<VIcon
|
|
icon="tabler-chart-timeline"
|
|
size="24"
|
|
class="me-3"
|
|
color="primary"
|
|
/>
|
|
<span class="text-h5 font-weight-medium">Project Gantt Chart</span>
|
|
</div>
|
|
|
|
<div class="d-flex gap-2">
|
|
<VBtn
|
|
color="primary"
|
|
prepend-icon="tabler-plus"
|
|
@click="addTask"
|
|
variant="flat"
|
|
rounded="lg"
|
|
size="small"
|
|
class="me-1"
|
|
title="Add New Task"
|
|
>
|
|
Add Task
|
|
</VBtn>
|
|
|
|
<VBtn
|
|
color="error"
|
|
variant="flat"
|
|
prepend-icon="tabler-trash"
|
|
:disabled="!selectedTask"
|
|
@click="deleteTask"
|
|
rounded="lg"
|
|
size="small"
|
|
class="me-1"
|
|
title="Delete Selected Task"
|
|
>
|
|
Delete Task
|
|
</VBtn>
|
|
|
|
<VMenu>
|
|
<template #activator="{ props }">
|
|
<VBtn
|
|
color="secondary"
|
|
variant="flat"
|
|
prepend-icon="tabler-download"
|
|
v-bind="props"
|
|
rounded="lg"
|
|
size="small"
|
|
class="me-1"
|
|
title="Export Gantt Chart"
|
|
>
|
|
Export
|
|
</VBtn>
|
|
</template>
|
|
|
|
<VList>
|
|
<VListItem @click="exportToPDF" :disabled="!gantt.exportToPDF">
|
|
<template #prepend>
|
|
<VIcon>tabler-file-type-pdf</VIcon>
|
|
</template>
|
|
<VListItemTitle>PDF</VListItemTitle>
|
|
</VListItem>
|
|
<VListItem @click="exportToPNG" :disabled="!gantt.exportToPNG">
|
|
<template #prepend>
|
|
<VIcon>tabler-file-type-png</VIcon>
|
|
</template>
|
|
<VListItemTitle>PNG</VListItemTitle>
|
|
</VListItem>
|
|
<VListItem @click="exportToExcel">
|
|
<template #prepend>
|
|
<VIcon>tabler-file-spreadsheet</VIcon>
|
|
</template>
|
|
<VListItemTitle>Excel/CSV</VListItemTitle>
|
|
</VListItem>
|
|
</VList>
|
|
</VMenu>
|
|
</div>
|
|
</VCardTitle>
|
|
|
|
<VDivider />
|
|
|
|
<!-- Toolbar -->
|
|
<VCardText class="py-4">
|
|
<VRow>
|
|
<VCol cols="12" md="3">
|
|
<VTextField
|
|
v-model="searchQuery"
|
|
label="Search Tasks"
|
|
prepend-inner-icon="tabler-search"
|
|
variant="outlined"
|
|
density="compact"
|
|
clearable
|
|
class="me-1"
|
|
title="Search for tasks by name"
|
|
/>
|
|
</VCol>
|
|
|
|
<VCol cols="12" md="3">
|
|
<VSelect
|
|
v-model="currentView"
|
|
:items="viewOptions"
|
|
item-title="label"
|
|
item-value="value"
|
|
label="View"
|
|
variant="outlined"
|
|
density="compact"
|
|
@update:model-value="changeView"
|
|
class="me-1"
|
|
title="Change Gantt chart view"
|
|
>
|
|
<template #item="{ props, item }">
|
|
<VListItem v-bind="props">
|
|
<template #prepend>
|
|
<VIcon :icon="item.raw.icon" />
|
|
</template>
|
|
</VListItem>
|
|
</template>
|
|
</VSelect>
|
|
</VCol>
|
|
|
|
<VCol cols="12" md="3">
|
|
<VBtn
|
|
color="info"
|
|
variant="flat"
|
|
prepend-icon="tabler-filter"
|
|
@click="showFilterPanel = !showFilterPanel"
|
|
rounded="lg"
|
|
size="small"
|
|
class="me-1"
|
|
title="Filter tasks"
|
|
>
|
|
Filter
|
|
</VBtn>
|
|
</VCol>
|
|
|
|
<VCol cols="12" md="3">
|
|
<VBtn
|
|
color="success"
|
|
variant="flat"
|
|
prepend-icon="tabler-users"
|
|
@click="showResourcePanel = !showResourcePanel"
|
|
rounded="lg"
|
|
size="small"
|
|
class="me-1"
|
|
title="Manage resources"
|
|
>
|
|
Resources
|
|
</VBtn>
|
|
</VCol>
|
|
</VRow>
|
|
</VCardText>
|
|
|
|
<VDivider />
|
|
|
|
<VCardText class="pa-0">
|
|
<div class="d-flex position-relative">
|
|
<!-- Main Gantt Chart -->
|
|
<div class="flex-grow-1">
|
|
<div
|
|
ref="ganttContainer"
|
|
class="gantt-container"
|
|
style="height: 600px; width: 100%;"
|
|
/>
|
|
|
|
<VOverlay
|
|
v-model="isLoading"
|
|
contained
|
|
persistent
|
|
class="align-center justify-center"
|
|
>
|
|
<VProgressCircular
|
|
indeterminate
|
|
color="primary"
|
|
size="64"
|
|
/>
|
|
</VOverlay>
|
|
</div>
|
|
|
|
<!-- Task Details Sidebar -->
|
|
<VNavigationDrawer
|
|
v-model="showTaskDetails"
|
|
location="end"
|
|
temporary
|
|
width="400"
|
|
class="gantt-sidebar"
|
|
>
|
|
<VCardTitle class="d-flex align-center justify-space-between">
|
|
<span>Task Details</span>
|
|
<VBtn
|
|
icon="tabler-x"
|
|
variant="flat"
|
|
@click="closeTaskDetails"
|
|
rounded="lg"
|
|
size="small"
|
|
title="Close task details"
|
|
/>
|
|
</VCardTitle>
|
|
|
|
<VDivider />
|
|
|
|
<VCardText v-if="selectedTask">
|
|
<VForm @submit.prevent="updateTask(selectedTask)">
|
|
<VRow>
|
|
<VCol cols="12">
|
|
<VTextField
|
|
v-model="selectedTask.text"
|
|
label="Task Name"
|
|
variant="outlined"
|
|
density="compact"
|
|
:rules="[v => !!v || 'Task name is required']"
|
|
title="Edit task name"
|
|
/>
|
|
</VCol>
|
|
|
|
<VCol cols="6">
|
|
<VTextField
|
|
v-model="selectedTask.start_date"
|
|
label="Start Date"
|
|
type="date"
|
|
variant="outlined"
|
|
density="compact"
|
|
title="Edit task start date"
|
|
/>
|
|
</VCol>
|
|
|
|
<VCol cols="6">
|
|
<VTextField
|
|
v-model.number="selectedTask.duration"
|
|
label="Duration (Days)"
|
|
type="number"
|
|
min="1"
|
|
variant="outlined"
|
|
density="compact"
|
|
:rules="[v => v > 0 || 'Duration must be positive']"
|
|
title="Edit task duration"
|
|
/>
|
|
</VCol>
|
|
|
|
<VCol cols="12">
|
|
<VSelect
|
|
v-model="selectedTask.priority"
|
|
:items="priorityOptions"
|
|
item-title="label"
|
|
item-value="value"
|
|
label="Priority"
|
|
variant="outlined"
|
|
density="compact"
|
|
title="Edit task priority"
|
|
>
|
|
<template #item="{ props, item }">
|
|
<VListItem v-bind="props">
|
|
<template #prepend>
|
|
<VIcon :icon="item.raw.icon" :color="item.raw.color" />
|
|
</template>
|
|
</VListItem>
|
|
</template>
|
|
</VSelect>
|
|
</VCol>
|
|
|
|
<VCol cols="12">
|
|
<VSelect
|
|
v-model="selectedTask.resource"
|
|
:items="projectData.resources"
|
|
item-title="name"
|
|
item-value="id"
|
|
label="Resource"
|
|
variant="outlined"
|
|
density="compact"
|
|
title="Edit task resource"
|
|
/>
|
|
</VCol>
|
|
|
|
<VCol cols="12">
|
|
<div class="mb-2">
|
|
<VLabel>Progress: {{ Math.round((selectedTask.progress || 0) * 100) }}%</VLabel>
|
|
</div>
|
|
<VSlider
|
|
v-model="selectedTask.progress"
|
|
min="0"
|
|
max="1"
|
|
step="0.1"
|
|
thumb-label
|
|
color="primary"
|
|
title="Edit task progress"
|
|
>
|
|
<template #thumb-label="{ modelValue }">
|
|
{{ Math.round(modelValue * 100) }}%
|
|
</template>
|
|
</VSlider>
|
|
</VCol>
|
|
|
|
<VCol cols="12">
|
|
<VBtn
|
|
type="submit"
|
|
color="primary"
|
|
block
|
|
variant="flat"
|
|
rounded="lg"
|
|
prepend-icon="tabler-check"
|
|
title="Save task changes"
|
|
>
|
|
Save Changes
|
|
</VBtn>
|
|
</VCol>
|
|
</VRow>
|
|
</VForm>
|
|
</VCardText>
|
|
</VNavigationDrawer>
|
|
|
|
<!-- Resource Panel -->
|
|
<VNavigationDrawer
|
|
v-model="showResourcePanel"
|
|
location="start"
|
|
temporary
|
|
width="300"
|
|
class="gantt-sidebar"
|
|
>
|
|
<VCardTitle class="d-flex align-center justify-space-between">
|
|
<span>Resource Management</span>
|
|
<VBtn
|
|
icon="tabler-x"
|
|
variant="flat"
|
|
@click="showResourcePanel = false"
|
|
rounded="lg"
|
|
size="small"
|
|
title="Close resource panel"
|
|
/>
|
|
</VCardTitle>
|
|
|
|
<VDivider />
|
|
|
|
<VCardText>
|
|
<VList>
|
|
<VListItem
|
|
v-for="resource in projectData.resources"
|
|
:key="resource.id"
|
|
:title="resource.name"
|
|
:subtitle="`Capacity: ${resource.capacity}%`"
|
|
class="gantt-resource-item"
|
|
>
|
|
<template #prepend>
|
|
<VAvatar color="primary" size="small">
|
|
<VIcon>tabler-user</VIcon>
|
|
</VAvatar>
|
|
</template>
|
|
|
|
<template #append>
|
|
<VChip
|
|
:color="getResourceUtilization(resource.id) > 80 ? 'error' : 'success'"
|
|
size="small"
|
|
variant="flat"
|
|
>
|
|
{{ getResourceUtilization(resource.id) }}%
|
|
</VChip>
|
|
</template>
|
|
</VListItem>
|
|
</VList>
|
|
</VCardText>
|
|
</VNavigationDrawer>
|
|
|
|
<!-- Filter Panel -->
|
|
<VDialog v-model="showFilterPanel" max-width="500">
|
|
<VCard>
|
|
<VCardTitle class="d-flex align-center justify-space-between">
|
|
<span>Filter Tasks</span>
|
|
<VBtn
|
|
icon="tabler-x"
|
|
variant="flat"
|
|
@click="showFilterPanel = false"
|
|
rounded="lg"
|
|
size="small"
|
|
title="Close filter panel"
|
|
/>
|
|
</VCardTitle>
|
|
<VDivider />
|
|
<VCardText>
|
|
<VRow>
|
|
<VCol cols="12">
|
|
<VSelect
|
|
v-model="filterOptions.priority"
|
|
label="Filter by Priority"
|
|
:items="[{ value: '', label: 'All Priorities' }, ...priorityOptions]"
|
|
item-title="label"
|
|
item-value="value"
|
|
variant="outlined"
|
|
density="compact"
|
|
clearable
|
|
>
|
|
<template #item="{ props, item }">
|
|
<VListItem v-bind="props">
|
|
<template #prepend v-if="item.raw.icon">
|
|
<VIcon :icon="item.raw.icon" :color="item.raw.color" />
|
|
</template>
|
|
</VListItem>
|
|
</template>
|
|
</VSelect>
|
|
</VCol>
|
|
<VCol cols="12">
|
|
<VSelect
|
|
v-model="filterOptions.resource"
|
|
label="Filter by Resource"
|
|
:items="[{ id: '', name: 'All Resources' }, ...projectData.resources]"
|
|
item-title="name"
|
|
item-value="id"
|
|
variant="outlined"
|
|
density="compact"
|
|
clearable
|
|
/>
|
|
</VCol>
|
|
<VCol cols="12">
|
|
<div class="mb-2">
|
|
<VLabel>Progress Range: {{ filterOptions.progressRange[0] }}% - {{ filterOptions.progressRange[1] }}%</VLabel>
|
|
</div>
|
|
<VRangeSlider
|
|
v-model="filterOptions.progressRange"
|
|
label="Progress Range"
|
|
min="0"
|
|
max="100"
|
|
step="10"
|
|
thumb-label
|
|
color="primary"
|
|
/>
|
|
</VCol>
|
|
</VRow>
|
|
</VCardText>
|
|
<VCardActions>
|
|
<VBtn @click="clearFilters" variant="text" color="secondary">
|
|
Clear Filters
|
|
</VBtn>
|
|
<VSpacer />
|
|
<VBtn @click="showFilterPanel = false" variant="text">Cancel</VBtn>
|
|
<VBtn color="primary" @click="applyFilters" variant="flat">Apply Filters</VBtn>
|
|
</VCardActions>
|
|
</VCard>
|
|
</VDialog>
|
|
</div>
|
|
</VCardText>
|
|
</VCard>
|
|
|
|
<VSnackbar
|
|
v-model="snackbar"
|
|
:color="snackbarText.includes('Error') ? 'error' : snackbarText.includes('Warning') ? 'warning' : 'success'"
|
|
location="bottom"
|
|
variant="flat"
|
|
rounded="lg"
|
|
timeout="4000"
|
|
>
|
|
<template #default>
|
|
<div class="d-flex align-center">
|
|
<VIcon
|
|
:icon="snackbarText.includes('Error') ? 'tabler-alert-circle' :
|
|
snackbarText.includes('Warning') ? 'tabler-alert-triangle' :
|
|
'tabler-check'"
|
|
class="me-2"
|
|
/>
|
|
{{ snackbarText }}
|
|
</div>
|
|
</template>
|
|
|
|
<template #actions>
|
|
<VBtn
|
|
icon="tabler-x"
|
|
variant="text"
|
|
@click="snackbar = false"
|
|
size="small"
|
|
/>
|
|
</template>
|
|
</VSnackbar>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.gantt-container {
|
|
:deep(.gantt_grid) {
|
|
background-color: rgb(var(--v-theme-surface));
|
|
border-right: 1px solid rgb(var(--v-theme-outline));
|
|
}
|
|
|
|
:deep(.gantt_task) {
|
|
background-color: rgb(var(--v-theme-primary));
|
|
border-color: rgb(var(--v-theme-primary));
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
transition: all 0.2s ease-in-out;
|
|
|
|
&:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
}
|
|
}
|
|
|
|
:deep(.gantt_task_progress) {
|
|
background-color: rgb(var(--v-theme-success));
|
|
border-radius: 0 8px 8px 0;
|
|
}
|
|
|
|
:deep(.gantt_task_line) {
|
|
border-color: rgb(var(--v-theme-primary));
|
|
background-color: rgb(var(--v-theme-primary));
|
|
}
|
|
|
|
:deep(.gantt_task_line .gantt_task_progress) {
|
|
background-color: rgb(var(--v-theme-success));
|
|
}
|
|
|
|
:deep(.gantt_row) {
|
|
background-color: rgb(var(--v-theme-surface));
|
|
border-bottom: 1px solid rgb(var(--v-theme-outline));
|
|
height: 40px; /* Increased row height */
|
|
align-items: center;
|
|
}
|
|
|
|
:deep(.gantt_row.gantt_selected) {
|
|
background-color: rgb(var(--v-theme-primary-container));
|
|
}
|
|
|
|
:deep(.gantt_cell) {
|
|
border-right: 1px solid rgb(var(--v-theme-outline));
|
|
}
|
|
|
|
:deep(.gantt_scale_cell) {
|
|
background-color: rgb(var(--v-theme-surface-variant));
|
|
border-right: 1px solid rgb(var(--v-theme-outline));
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
|
|
:deep(.gantt_grid_head_cell) {
|
|
background-color: rgb(var(--v-theme-surface-variant));
|
|
border-right: 1px solid rgb(var(--v-theme-outline));
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
font-weight: 600;
|
|
}
|
|
|
|
:deep(.gantt_link_control) {
|
|
background-color: rgb(var(--v-theme-secondary));
|
|
}
|
|
|
|
:deep(.gantt_link_point) {
|
|
background-color: rgb(var(--v-theme-secondary));
|
|
}
|
|
|
|
:deep(.gantt_drag_marker) {
|
|
background-color: rgb(var(--v-theme-primary));
|
|
border-color: rgb(var(--v-theme-primary));
|
|
}
|
|
|
|
:deep(.gantt_drag_progress) {
|
|
background-color: rgb(var(--v-theme-success));
|
|
}
|
|
|
|
:deep(.gantt_drag_link) {
|
|
background-color: rgb(var(--v-theme-secondary));
|
|
}
|
|
|
|
:deep(.gantt_row_odd) {
|
|
background-color: rgb(var(--v-theme-surface));
|
|
}
|
|
|
|
:deep(.gantt_row_even) {
|
|
background-color: rgb(var(--v-theme-surface-variant));
|
|
}
|
|
|
|
:deep(.gantt_tree_icon) {
|
|
color: rgb(var(--v-theme-on-surface));
|
|
}
|
|
|
|
:deep(.gantt_tree_content) {
|
|
color: rgb(var(--v-theme-on-surface));
|
|
}
|
|
|
|
:deep(.gantt_tree_icon.gantt_open) {
|
|
color: rgb(var(--v-theme-primary));
|
|
}
|
|
|
|
:deep(.gantt_tree_icon.gantt_close) {
|
|
color: rgb(var(--v-theme-primary));
|
|
}
|
|
|
|
// Priority styling
|
|
:deep(.high-priority) {
|
|
background-color: rgb(var(--v-theme-error)) !important;
|
|
border-color: rgb(var(--v-theme-error)) !important;
|
|
}
|
|
|
|
:deep(.medium-priority) {
|
|
background-color: rgb(var(--v-theme-warning)) !important;
|
|
border-color: rgb(var(--v-theme-warning)) !important;
|
|
}
|
|
|
|
:deep(.low-priority) {
|
|
background-color: rgb(var(--v-theme-success)) !important;
|
|
border-color: rgb(var(--v-theme-success)) !important;
|
|
}
|
|
|
|
// Search highlight
|
|
:deep(.search-highlight) {
|
|
background-color: rgb(var(--v-theme-secondary)) !important;
|
|
border-color: rgb(var(--v-theme-secondary)) !important;
|
|
}
|
|
}
|
|
|
|
// RTL Support
|
|
:deep(.gantt_grid) {
|
|
direction: rtl;
|
|
}
|
|
|
|
:deep(.gantt_task) {
|
|
direction: ltr;
|
|
}
|
|
|
|
:deep(.gantt_scale) {
|
|
direction: ltr;
|
|
}
|
|
|
|
.gantt-sidebar {
|
|
.v-navigation-drawer__content {
|
|
padding: 0;
|
|
}
|
|
|
|
.v-card {
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.v-list-item {
|
|
border-radius: 8px;
|
|
margin: 4px 8px;
|
|
|
|
&:hover {
|
|
background-color: rgba(var(--v-theme-primary), 0.1);
|
|
}
|
|
}
|
|
|
|
.gantt-resource-item {
|
|
border-radius: 8px;
|
|
margin: 4px 8px;
|
|
|
|
&:hover {
|
|
background-color: rgba(var(--v-theme-primary), 0.1);
|
|
}
|
|
}
|
|
}
|
|
|
|
.gantt-card {
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.gantt-task-rounded {
|
|
border-radius: 8px;
|
|
}
|
|
</style>
|