313 lines
12 KiB
Vue
313 lines
12 KiB
Vue
<template>
|
||
<div class="min-h-screen bg-slate-900 text-slate-200">
|
||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||
<div class="flex items-center justify-between mb-8">
|
||
<h1 class="text-3xl font-bold text-white flex items-center gap-3">
|
||
<span class="text-blue-500">📋</span> Мои Задачи
|
||
</h1>
|
||
<div class="flex items-center gap-3">
|
||
<button @click="showCreateModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium shadow-lg shadow-blue-900/20 transition flex items-center gap-2">
|
||
<span>➕</span> Новая задача
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Create Task Modal -->
|
||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" @click.self="showCreateModal = false">
|
||
<div class="bg-slate-800 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||
<h2 class="text-xl font-bold text-white mb-6">Создать новую задачу</h2>
|
||
<form @submit.prevent="createTask" class="space-y-4">
|
||
<div>
|
||
<label class="text-xs text-slate-500 uppercase font-bold">Название задачи</label>
|
||
<input v-model="newTask.title" required class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500" placeholder="Что нужно сделать?" />
|
||
</div>
|
||
|
||
<div>
|
||
<label class="text-xs text-slate-500 uppercase font-bold">Проект (Канвас)</label>
|
||
<select v-model="newTask.canvasId" required class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
|
||
<option value="" disabled>Выберите проект...</option>
|
||
<option v-for="c in availableCanvases" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="text-xs text-slate-500 uppercase font-bold">Приоритет</label>
|
||
<select v-model="newTask.priority" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
|
||
<option value="low">Низкий</option>
|
||
<option value="medium">Средний</option>
|
||
<option value="high">Высокий</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="text-xs text-slate-500 uppercase font-bold">Статус</label>
|
||
<select v-model="newTask.state" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
|
||
<option v-for="col in columns" :key="col.id" :value="col.id">{{ col.title }}</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-3 pt-4">
|
||
<button type="submit" :disabled="creating" class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition disabled:opacity-50">
|
||
{{ creating ? 'Создание...' : 'Создать задачу' }}
|
||
</button>
|
||
<button type="button" @click="showCreateModal = false" class="px-6 py-3 bg-slate-700 hover:bg-slate-600 text-white rounded-xl font-bold transition">Отмена</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="loading" class="flex items-center justify-center h-64">
|
||
<div class="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||
</div>
|
||
|
||
<div v-else-if="tasks.length === 0" class="flex flex-col items-center justify-center h-64 bg-slate-800 rounded-xl border border-slate-700">
|
||
<span class="text-4xl mb-4">📭</span>
|
||
<h3 class="text-xl font-medium text-white mb-2">У вас пока нет задач</h3>
|
||
<p class="text-slate-400">Вам не назначено ни одной задачи в проектах.</p>
|
||
</div>
|
||
|
||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||
<!-- Kanban Columns -->
|
||
<div v-for="col in columns" :key="col.id" class="bg-slate-800 rounded-xl border border-slate-700 flex flex-col max-h-[80vh]">
|
||
<div class="p-4 border-b border-slate-700 flex items-center justify-between sticky top-0 bg-slate-800 rounded-t-xl z-10">
|
||
<h3 class="font-bold text-white flex items-center gap-2">
|
||
<span :class="col.color">{{ col.icon }}</span> {{ col.title }}
|
||
</h3>
|
||
<span class="bg-slate-700 text-slate-300 text-xs px-2 py-1 rounded-full font-medium">
|
||
{{ getTasksForColumn(col.id).length }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="p-4 flex-1 overflow-y-auto space-y-4 custom-scrollbar min-h-[200px]">
|
||
<ClientOnly>
|
||
<draggable
|
||
v-model="columnsData[col.id]"
|
||
group="tasks"
|
||
item-key="id"
|
||
class="space-y-4 min-h-[200px]"
|
||
@change="(evt) => onTaskMove(evt, col.id)"
|
||
>
|
||
<template #item="{ element: task }">
|
||
<div
|
||
class="bg-slate-900 border border-slate-600 rounded-lg p-4 shadow-sm hover:border-blue-500 transition-colors cursor-pointer group"
|
||
@click="openTask(task)"
|
||
>
|
||
<div class="flex items-start justify-between mb-2">
|
||
<div class="text-xs font-medium px-2 py-0.5 rounded" :class="getPriorityClass(task)">
|
||
{{ getPriorityLabel(task) }}
|
||
</div>
|
||
<div class="text-[10px] text-slate-500 font-mono">{{ task.id.split('-')[1] || task.id }}</div>
|
||
</div>
|
||
|
||
<h4 class="font-medium text-white mb-2 line-clamp-2">{{ getTaskTitle(task) }}</h4>
|
||
|
||
<div class="flex items-center justify-between mt-4">
|
||
<div class="text-xs text-slate-400 flex items-center gap-1">
|
||
<span>🗺️</span>
|
||
<span class="truncate max-w-[120px]">{{ task.canvasName }}</span>
|
||
</div>
|
||
<div v-if="task.alarm || task.data?.alarm" class="text-xs text-orange-400 flex items-center gap-1" :title="new Date(task.alarm || task.data?.alarm).toLocaleString('ru-RU')">
|
||
<span>⏰</span>
|
||
<span>{{ formatShortDate(task.alarm || task.data?.alarm) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</draggable>
|
||
</ClientOnly>
|
||
|
||
<div v-if="columnsData[col.id].length === 0" class="text-center py-6 border-2 border-dashed border-slate-700 rounded-lg pointer-events-none">
|
||
<span class="text-slate-500 text-sm">Нет задач</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, computed, reactive } from 'vue'
|
||
import draggable from 'vuedraggable'
|
||
|
||
const auth = useAuth()
|
||
const loading = ref(true)
|
||
const tasks = ref([])
|
||
const columnsData = reactive({
|
||
todo: [],
|
||
in_progress: [],
|
||
review: [],
|
||
blocked: [],
|
||
completed: []
|
||
})
|
||
|
||
const showCreateModal = ref(false)
|
||
const creating = ref(false)
|
||
const availableCanvases = ref([])
|
||
const newTask = reactive({
|
||
title: '',
|
||
canvasId: '',
|
||
priority: 'medium',
|
||
state: 'todo'
|
||
})
|
||
|
||
const columns = [
|
||
{ id: 'todo', title: 'К выполнению', icon: '📋', color: 'text-slate-400' },
|
||
{ id: 'in_progress', title: 'В работе', icon: '⏳', color: 'text-blue-400' },
|
||
{ id: 'review', title: 'На проверке', icon: '👀', color: 'text-purple-400' },
|
||
{ id: 'blocked', title: 'Заблокировано', icon: '⛔', color: 'text-red-400' },
|
||
{ id: 'completed', title: 'Завершено', icon: '✅', color: 'text-emerald-400' }
|
||
]
|
||
|
||
// Fetch all tasks for current user
|
||
const fetchTasks = async () => {
|
||
try {
|
||
const res = await fetch('/api/tasks', {
|
||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||
})
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
tasks.value = data.tasks || []
|
||
|
||
// Split tasks into columns
|
||
columns.forEach(col => {
|
||
columnsData[col.id] = tasks.value.filter(t => {
|
||
const taskState = t.state || t.data?.state || 'todo'
|
||
return taskState === col.id
|
||
})
|
||
})
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load tasks', e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
const fetchCanvases = async () => {
|
||
try {
|
||
const res = await $fetch('/api/canvas')
|
||
availableCanvases.value = res.canvases || []
|
||
if (availableCanvases.value.length > 0) {
|
||
newTask.canvasId = availableCanvases.value[0].id
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load canvases', e)
|
||
}
|
||
}
|
||
|
||
const createTask = async () => {
|
||
creating.value = true
|
||
try {
|
||
await $fetch('/api/tasks/create', {
|
||
method: 'POST',
|
||
body: newTask,
|
||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||
})
|
||
showCreateModal.value = false
|
||
newTask.title = ''
|
||
await fetchTasks() // Refresh list
|
||
} catch (e) {
|
||
alert('Ошибка при создании задачи: ' + (e.data?.message || e.message))
|
||
} finally {
|
||
creating.value = false
|
||
}
|
||
}
|
||
|
||
const onTaskMove = async (evt, newStatus) => {
|
||
if (evt.added) {
|
||
const task = evt.added.element
|
||
console.log(`Moving task ${task.id} to ${newStatus}`)
|
||
|
||
try {
|
||
await $fetch('/api/tasks/update-status', {
|
||
method: 'POST',
|
||
body: {
|
||
taskId: task.id,
|
||
canvasId: task.canvasId,
|
||
newState: newStatus
|
||
},
|
||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||
})
|
||
} catch (e) {
|
||
console.error('Failed to update task status', e)
|
||
// Rollback on error? (In a production app, we should)
|
||
alert('Не удалось обновить статус задачи на сервере')
|
||
}
|
||
}
|
||
}
|
||
|
||
const getTasksForColumn = (state) => {
|
||
return tasks.value.filter(t => {
|
||
const taskState = t.state || t.data?.state || 'todo'
|
||
return taskState === state
|
||
})
|
||
}
|
||
|
||
const getTaskTitle = (task) => {
|
||
if (task.label) return task.label
|
||
const text = task.text || task.data?.text
|
||
if (text) {
|
||
const lines = text.split('\n')
|
||
const firstLine = lines.find(l => l.trim().startsWith('# ')) || lines.find(l => l.trim())
|
||
return firstLine ? firstLine.replace('# ', '').trim().slice(0, 50) : 'Без названия'
|
||
}
|
||
return 'Задача'
|
||
}
|
||
|
||
const getPriorityClass = (task) => {
|
||
const priority = task.priority || task.data?.priority
|
||
if (priority === 'high') return 'bg-red-900/50 text-red-300'
|
||
if (priority === 'low') return 'bg-slate-700 text-slate-300'
|
||
return 'bg-blue-900/50 text-blue-300' // medium
|
||
}
|
||
|
||
const getPriorityLabel = (task) => {
|
||
const priority = task.priority || task.data?.priority
|
||
if (priority === 'high') return 'Высокий'
|
||
if (priority === 'low') return 'Низкий'
|
||
return 'Средний'
|
||
}
|
||
|
||
const formatShortDate = (dateStr) => {
|
||
try {
|
||
const d = new Date(dateStr)
|
||
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
||
} catch {
|
||
return dateStr
|
||
}
|
||
}
|
||
|
||
const openTask = (task) => {
|
||
navigateTo(`/canvas/${task.canvasId}`)
|
||
}
|
||
|
||
onMounted(async () => {
|
||
if (!auth.user.value) {
|
||
await auth.init()
|
||
}
|
||
await Promise.all([
|
||
fetchTasks(),
|
||
fetchCanvases()
|
||
])
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.custom-scrollbar {
|
||
scrollbar-width: thin;
|
||
scrollbar-color: #475569 transparent;
|
||
}
|
||
.custom-scrollbar::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
.custom-scrollbar::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||
background-color: #475569;
|
||
border-radius: 4px;
|
||
}
|
||
</style>
|