319 lines
11 KiB
Vue
319 lines
11 KiB
Vue
<template>
|
||
<div
|
||
class="fixed bottom-0 left-0 right-0 z-30 transition-all duration-300 ease-in-out font-sans flex flex-col"
|
||
:class="[isOpen ? 'h-80' : 'h-10']"
|
||
>
|
||
<!-- Handle / Toggle Bar -->
|
||
<div
|
||
@click="isOpen = !isOpen"
|
||
class="h-10 bg-slate-900 border-t border-slate-700/80 hover:bg-slate-800 cursor-pointer flex items-center px-4 justify-between select-none transition"
|
||
>
|
||
<div class="flex items-center gap-2 text-slate-200 font-bold text-xs">
|
||
<span>📅</span>
|
||
<span>Диаграмма Ганта</span>
|
||
<span class="px-1.5 py-0.5 bg-slate-800 text-[10px] text-slate-400 rounded-full border border-slate-700 font-medium">
|
||
Задач: {{ tasks.length }}
|
||
</span>
|
||
</div>
|
||
<div class="text-slate-400 text-xs">
|
||
{{ isOpen ? '▼ Свернуть' : '▲ Развернуть' }}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Timeline Body -->
|
||
<div
|
||
v-if="isOpen"
|
||
class="flex-1 bg-slate-950/95 backdrop-blur-md border-t border-slate-800 flex overflow-hidden min-w-0"
|
||
>
|
||
<!-- Task Titles Sidebar (Left) -->
|
||
<div class="w-64 border-r border-slate-800 flex flex-col shrink-0">
|
||
<div class="h-8 border-b border-slate-800 bg-slate-900/40 px-3 flex items-center text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
||
Задача
|
||
</div>
|
||
<div class="flex-1 overflow-y-auto divide-y divide-slate-900/60 scrollbar-thin">
|
||
<div
|
||
v-for="task in tasks"
|
||
:key="task.id"
|
||
@click="$emit('select-node', task.id)"
|
||
class="h-10 px-3 flex items-center text-xs text-slate-300 hover:text-white hover:bg-slate-800/30 cursor-pointer transition select-none truncate font-medium"
|
||
title="Кликните для фокусировки на холсте"
|
||
>
|
||
<span class="mr-1.5">{{ getStatusIcon(task.data.state) }}</span>
|
||
<span class="truncate">{{ task.data.label || 'Без названия' }}</span>
|
||
</div>
|
||
<div v-if="tasks.length === 0" class="p-6 text-center text-xs text-slate-600">
|
||
Нет задач на холсте
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Timeline Chart (Right) -->
|
||
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
|
||
<!-- Date Columns Header -->
|
||
<div
|
||
class="h-8 border-b border-slate-800 bg-slate-900/40 flex overflow-x-auto overflow-y-hidden select-none scrollbar-none"
|
||
ref="timelineHeader"
|
||
@scroll="syncScrollLeft"
|
||
>
|
||
<div
|
||
v-for="(day, idx) in timelineDays"
|
||
:key="idx"
|
||
class="h-full border-r border-slate-900/40 shrink-0 flex flex-col items-center justify-center text-[9px] font-semibold text-slate-400"
|
||
:style="{ width: `${dayWidth}px` }"
|
||
>
|
||
<span>{{ day.dayOfWeek }}</span>
|
||
<span class="text-slate-500 font-normal">{{ day.dateStr }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Task Bars Body -->
|
||
<div
|
||
class="flex-1 overflow-auto divide-y divide-slate-900/60 scrollbar-thin"
|
||
ref="timelineBody"
|
||
@scroll="syncScrollRight"
|
||
>
|
||
<div
|
||
v-for="task in tasks"
|
||
:key="task.id"
|
||
class="h-10 relative flex items-center bg-slate-950/20 hover:bg-slate-900/30 transition group"
|
||
>
|
||
<!-- Timeline Grid Background Lines -->
|
||
<div class="absolute inset-0 flex pointer-events-none">
|
||
<div
|
||
v-for="idx in timelineDays.length"
|
||
:key="idx"
|
||
class="h-full border-r border-slate-900/40 shrink-0"
|
||
:style="{ width: `${dayWidth}px` }"
|
||
></div>
|
||
</div>
|
||
|
||
<!-- Task Gantt Bar -->
|
||
<div
|
||
v-if="hasValidDates(task)"
|
||
@click="$emit('select-node', task.id)"
|
||
class="absolute h-5 rounded-md border text-[10px] font-bold text-white px-2 flex items-center truncate cursor-pointer transition shadow-md hover:scale-[1.01] z-10"
|
||
:class="getBarClasses(task)"
|
||
:style="getBarStyles(task)"
|
||
>
|
||
<span class="truncate">{{ task.data.label }}</span>
|
||
<!-- Tooltip on hover -->
|
||
<div class="absolute bottom-6 left-0 hidden group-hover:block bg-slate-900 border border-slate-700 text-[10px] rounded p-2 text-slate-300 w-48 shadow-xl pointer-events-none whitespace-normal leading-relaxed z-20">
|
||
<div class="font-bold text-white truncate mb-1">{{ task.data.label }}</div>
|
||
<div>Статус: <span class="font-medium text-slate-200">{{ getStatusLabel(task.data.state) }}</span></div>
|
||
<div>Приоритет: <span class="font-medium text-slate-200">{{ getPriorityLabel(task.data.priority) }}</span></div>
|
||
<div v-if="task.data.assigned_to">Исполнитель: <span class="font-medium text-slate-200">{{ task.data.assigned_to }}</span></div>
|
||
<div class="text-[9px] text-slate-500 mt-1 border-t border-slate-800 pt-1">
|
||
С: {{ formatDate(task.data.start_date) }}<br/>
|
||
По: {{ formatDate(task.data.alarm) }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="tasks.length === 0" class="h-full flex items-center justify-center text-xs text-slate-600">
|
||
Создайте задачи и задайте им даты начала/дедлайны
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed } from 'vue'
|
||
|
||
const props = defineProps({
|
||
elements: { type: Array, default: () => [] }
|
||
})
|
||
|
||
defineEmits(['select-node'])
|
||
|
||
const isOpen = ref(false)
|
||
const dayWidth = 60 // Width of one day column in pixels
|
||
|
||
const timelineHeader = ref(null)
|
||
const timelineBody = ref(null)
|
||
|
||
// Scroll synchronization between header and body
|
||
const syncScrollLeft = (e) => {
|
||
if (timelineBody.value) {
|
||
timelineBody.value.scrollLeft = e.target.scrollLeft
|
||
}
|
||
}
|
||
const syncScrollRight = (e) => {
|
||
if (timelineHeader.value) {
|
||
timelineHeader.value.scrollLeft = e.target.scrollLeft
|
||
}
|
||
}
|
||
|
||
// Filter tasks
|
||
const tasks = computed(() => {
|
||
return props.elements.filter(el => el.type === 'task')
|
||
})
|
||
|
||
// Calculate overall min/max dates
|
||
const dateRange = computed(() => {
|
||
let minDate = new Date()
|
||
let maxDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) // Default 14 days from now
|
||
|
||
const taskDates = []
|
||
tasks.value.forEach(t => {
|
||
if (t.data.start_date) taskDates.push(new Date(t.data.start_date))
|
||
if (t.data.alarm) taskDates.push(new Date(t.data.alarm))
|
||
})
|
||
|
||
if (taskDates.length > 0) {
|
||
minDate = new Date(Math.min(...taskDates))
|
||
maxDate = new Date(Math.max(...taskDates))
|
||
}
|
||
|
||
// Add 2 days padding to sides
|
||
minDate.setDate(minDate.getDate() - 2)
|
||
maxDate.setDate(maxDate.getDate() + 5)
|
||
|
||
// Cap timeline length at 30 days to prevent rendering overflow
|
||
const diffTime = Math.abs(maxDate - minDate)
|
||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||
if (diffDays > 30) {
|
||
maxDate = new Date(minDate.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||
}
|
||
|
||
// Reset to midnight
|
||
minDate.setHours(0, 0, 0, 0)
|
||
maxDate.setHours(23, 59, 59, 999)
|
||
|
||
return { minDate, maxDate }
|
||
})
|
||
|
||
// Generate list of days for timeline header
|
||
const timelineDays = computed(() => {
|
||
const days = []
|
||
const current = new Date(dateRange.value.minDate)
|
||
const end = dateRange.value.maxDate
|
||
const ruDays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
|
||
|
||
while (current <= end) {
|
||
days.push({
|
||
date: new Date(current),
|
||
dateStr: `${current.getDate().toString().padStart(2, '0')}.${(current.getMonth() + 1).toString().padStart(2, '0')}`,
|
||
dayOfWeek: ruDays[current.getDay()]
|
||
})
|
||
current.setDate(current.getDate() + 1)
|
||
}
|
||
return days
|
||
})
|
||
|
||
// Check if task has valid start and end date
|
||
const hasValidDates = (task) => {
|
||
return !!(task.data.start_date || task.data.alarm)
|
||
}
|
||
|
||
// Format single date for tooltips
|
||
const formatDate = (dateStr) => {
|
||
if (!dateStr) return 'Не указано'
|
||
const date = new Date(dateStr)
|
||
return date.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||
}
|
||
|
||
// Calculate absolute left offset and width in percent or pixels for Gantt Bar
|
||
const getBarStyles = (task) => {
|
||
const minTime = dateRange.value.minDate.getTime()
|
||
const maxTime = dateRange.value.maxDate.getTime()
|
||
const totalDuration = maxTime - minTime
|
||
|
||
let start = task.data.start_date ? new Date(task.data.start_date).getTime() : Date.now()
|
||
let end = task.data.alarm ? new Date(task.data.alarm).getTime() : start + 2 * 24 * 60 * 60 * 1000 // 2 days default
|
||
|
||
if (start > end) {
|
||
// Correct order if user reversed dates
|
||
const tmp = start
|
||
start = end
|
||
end = tmp
|
||
}
|
||
|
||
// Boundary checks
|
||
start = Math.max(start, minTime)
|
||
end = Math.min(end, maxTime)
|
||
|
||
const leftPercent = ((start - minTime) / totalDuration) * 100
|
||
const widthPercent = ((end - start) / totalDuration) * 100
|
||
|
||
// Calculate actual pixel offsets based on timeline width
|
||
const totalDays = timelineDays.value.length
|
||
const totalPixels = totalDays * dayWidth
|
||
|
||
const leftPx = (leftPercent / 100) * totalPixels
|
||
const widthPx = Math.max((widthPercent / 100) * totalPixels, 20) // min 20px width
|
||
|
||
return {
|
||
left: `${leftPx}px`,
|
||
width: `${widthPx}px`
|
||
}
|
||
}
|
||
|
||
// Style colors based on task status
|
||
const getBarClasses = (task) => {
|
||
const state = task.data.state || 'todo'
|
||
switch (state) {
|
||
case 'completed':
|
||
return 'bg-emerald-600/30 border-emerald-500 hover:bg-emerald-500/40 text-emerald-300'
|
||
case 'in_progress':
|
||
return 'bg-amber-600/30 border-amber-500 hover:bg-amber-500/40 text-amber-300'
|
||
case 'review':
|
||
return 'bg-indigo-600/30 border-indigo-500 hover:bg-indigo-500/40 text-indigo-300'
|
||
case 'blocked':
|
||
return 'bg-red-600/30 border-red-500 hover:bg-red-500/40 text-red-300'
|
||
default:
|
||
return 'bg-blue-600/30 border-blue-500 hover:bg-blue-500/40 text-blue-300'
|
||
}
|
||
}
|
||
|
||
const getStatusIcon = (state) => {
|
||
switch (state) {
|
||
case 'completed': return '✅'
|
||
case 'in_progress': return '⏳'
|
||
case 'review': return '👀'
|
||
case 'blocked': return '⛔'
|
||
default: return '📋'
|
||
}
|
||
}
|
||
|
||
const getStatusLabel = (state) => {
|
||
switch (state) {
|
||
case 'completed': return 'Завершено'
|
||
case 'in_progress': return 'В работе'
|
||
case 'review': return 'На проверке'
|
||
case 'blocked': return 'Заблокировано'
|
||
default: return 'К выполнению'
|
||
}
|
||
}
|
||
|
||
const getPriorityLabel = (priority) => {
|
||
switch (priority) {
|
||
case 'high': return 'Высокий'
|
||
case 'low': return 'Низкий'
|
||
default: return 'Средний'
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.scrollbar-thin::-webkit-scrollbar {
|
||
width: 6px;
|
||
height: 6px;
|
||
}
|
||
.scrollbar-thin::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||
background: #334155;
|
||
border-radius: 3px;
|
||
}
|
||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||
background: #475569;
|
||
}
|
||
.scrollbar-none::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
</style>
|