feat: enhance task persistence and implement centralized kanban board
This commit is contained in:
+103
-33
@@ -23,8 +23,8 @@
|
||||
<div class="flex-1 flex flex-col relative min-w-0">
|
||||
<!-- Toolbar -->
|
||||
<div class="h-10 bg-slate-900 border-b border-slate-700 flex items-center px-3 gap-2 z-20">
|
||||
<span class="text-sm font-semibold text-slate-300 mr-2">{{ canvasName }}</span>
|
||||
<span v-if="unsavedChanges.isDirty" class="px-2 py-1 bg-yellow-600 text-yellow-100 rounded text-xs font-medium">
|
||||
<input v-model="canvasName" @change="saveCanvas" class="text-sm font-semibold text-slate-300 mr-2 bg-transparent border border-transparent hover:border-slate-600 focus:border-blue-500 focus:bg-slate-800 outline-none rounded px-2 py-1 w-64 transition" title="Переименовать карту" />
|
||||
<span v-if="isDirty" class="px-2 py-1 bg-yellow-600 text-yellow-100 rounded text-xs font-medium">
|
||||
● Несохранено
|
||||
</span>
|
||||
<button @click="addNodeAtCenter" class="px-2 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs">+ Узел</button>
|
||||
@@ -78,14 +78,7 @@
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
<div v-if="selectedNode" class="space-y-3">
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">ID</label>
|
||||
<input :value="selectedNode.id" disabled class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-400 font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Тип</label>
|
||||
<input :value="selectedNode.type" disabled class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-300 capitalize" />
|
||||
</div>
|
||||
<!-- Hidden internal ID & Type for cleaner UI -->
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Название</label>
|
||||
<input v-model="selectedNode.data.label" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
@@ -99,19 +92,34 @@
|
||||
<input v-model="selectedNode.data.file" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="selectedNode.data.state !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Состояние</label>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Статус Задачи</label>
|
||||
<select v-model="selectedNode.data.state" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="pending">Ожидает</option>
|
||||
<option value="in_progress">В процессе</option>
|
||||
<option value="completed">Завершено</option>
|
||||
<option value="todo">📋 К выполнению (To Do)</option>
|
||||
<option value="in_progress">⏳ В работе (In Progress)</option>
|
||||
<option value="review">👀 На проверке (Review)</option>
|
||||
<option value="completed">✅ Завершено (Done)</option>
|
||||
<option value="blocked">⛔ Заблокировано (Blocked)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedNode.data.priority !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Приоритет</label>
|
||||
<select v-model="selectedNode.data.priority" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="low">🧊 Низкий</option>
|
||||
<option value="medium">⚡ Средний</option>
|
||||
<option value="high">🔥 Высокий</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedNode.data.assigned_to !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Назначено</label>
|
||||
<input v-model="selectedNode.data.assigned_to" @blur="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Исполнитель</label>
|
||||
<select v-model="selectedNode.data.assigned_to" @change="commitNodeChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="">Не назначено</option>
|
||||
<option v-for="u in usersList" :key="u.username" :value="u.username">
|
||||
{{ u.displayName || u.username }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="selectedNode.data.alarm !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Напоминание</label>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Дедлайн (Срок)</label>
|
||||
<input v-model="selectedNode.data.alarm" @blur="commitNodeChange" type="datetime-local" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="selectedNode.data.script_id !== undefined">
|
||||
@@ -153,7 +161,7 @@ const props = defineProps({
|
||||
|
||||
const { loadCanvas, updateNodeState: apiUpdateNodeState } = useCanvas()
|
||||
const gitSync = useGitSync()
|
||||
const unsavedChanges = useUnsavedChanges()
|
||||
const { isDirty, trackChanges, markAsSaved } = useUnsavedChanges()
|
||||
const auth = useAuth()
|
||||
const authHeaders = computed(() => ({ Authorization: `Bearer ${auth.token.value}` }))
|
||||
const elements = ref([])
|
||||
@@ -174,20 +182,42 @@ const loadData = async () => {
|
||||
const data = await loadCanvas(props.canvasId)
|
||||
elements.value = transformToFlowElements(data)
|
||||
// Mark initial state as saved
|
||||
unsavedChanges.markAsSaved(data)
|
||||
markAsSaved(data)
|
||||
}
|
||||
|
||||
// Transform Obsidian canvas to Vue Flow elements
|
||||
const transformToFlowElements = (canvasData) => {
|
||||
const nodes = (canvasData.nodes || []).map(node => ({
|
||||
id: node.id,
|
||||
type: node.type === 'file' ? 'file' : node.type === 'ai' ? 'ai' : node.type === 'git' ? 'git' : 'text',
|
||||
position: { x: node.x, y: node.y },
|
||||
type: node.type === 'file' ? 'file' : node.type === 'ai' ? 'ai' : node.type === 'git' ? 'git' : node.type === 'task' ? 'task' : node.type === 'webhook' ? 'webhook' : 'text',
|
||||
position: { x: Number(node.x) || 0, y: Number(node.y) || 0 },
|
||||
data: {
|
||||
...node,
|
||||
width: node.width || 300,
|
||||
height: node.height || 200,
|
||||
label: node.label || node.text?.slice(0, 20) || node.file || 'Node'
|
||||
label: node.label || node.text?.slice(0, 20) || node.file || 'Node',
|
||||
// Ensure specific fields exist so the properties panel shows them
|
||||
...(node.type === 'task' && {
|
||||
text: node.text ?? '',
|
||||
state: node.state ?? 'todo',
|
||||
priority: node.priority ?? 'medium',
|
||||
assigned_to: node.assigned_to ?? '',
|
||||
alarm: node.alarm ?? '',
|
||||
script_id: node.script_id ?? ''
|
||||
}),
|
||||
...(node.type === 'text' && { text: node.text ?? '' }),
|
||||
...(node.type === 'file' && { file: node.file ?? '' }),
|
||||
...(node.type === 'ai' && { prompt: node.prompt ?? '' }),
|
||||
...(node.type === 'webhook' && {
|
||||
script_id: node.script_id ?? '',
|
||||
url: node.url ?? '',
|
||||
method: node.method ?? 'POST'
|
||||
}),
|
||||
...(node.type === 'git' && {
|
||||
repo: node.repo ?? '',
|
||||
branch: node.branch ?? 'main',
|
||||
message: node.message ?? ''
|
||||
})
|
||||
},
|
||||
style: {
|
||||
width: `${node.width || 300}px`,
|
||||
@@ -215,18 +245,25 @@ const transformToFlowElements = (canvasData) => {
|
||||
const transformToObsidianCanvas = (flowElements) => {
|
||||
const nodes = flowElements.filter(el => el.position !== undefined).map(node => ({
|
||||
id: node.id,
|
||||
type: node.type === 'file' ? 'file' : 'text',
|
||||
x: Math.round(node.position.x),
|
||||
y: Math.round(node.position.y),
|
||||
type: node.type === 'file' ? 'file' : node.type === 'task' ? 'task' : node.type === 'ai' ? 'ai' : node.type === 'webhook' ? 'webhook' : node.type === 'git' ? 'git' : 'text',
|
||||
x: Math.round(Number(node.position.x) || 0),
|
||||
y: Math.round(Number(node.position.y) || 0),
|
||||
width: node.data.width || 300,
|
||||
height: node.data.height || 200,
|
||||
text: node.data.text,
|
||||
label: node.data.label,
|
||||
file: node.data.file,
|
||||
state: node.data.state,
|
||||
priority: node.data.priority,
|
||||
assigned_to: node.data.assigned_to,
|
||||
alarm: node.data.alarm,
|
||||
script_id: node.data.script_id,
|
||||
prompt: node.data.prompt,
|
||||
url: node.data.url,
|
||||
method: node.data.method,
|
||||
repo: node.data.repo,
|
||||
branch: node.data.branch,
|
||||
message: node.data.message,
|
||||
style: {
|
||||
background: node.style?.background || '#1e293b',
|
||||
border: node.style?.border?.replace('1px solid ', '') || '#334155'
|
||||
@@ -281,7 +318,8 @@ const createNode = (type, x, y) => {
|
||||
nodeData.data.file = 'docs/file.md'
|
||||
break
|
||||
case 'task':
|
||||
nodeData.data.state = 'pending'
|
||||
nodeData.data.state = 'todo'
|
||||
nodeData.data.priority = 'medium'
|
||||
nodeData.data.assigned_to = ''
|
||||
nodeData.data.alarm = ''
|
||||
nodeData.data.script_id = ''
|
||||
@@ -324,10 +362,13 @@ const commitNodeChange = () => {
|
||||
// Trigger reactivity
|
||||
const idx = elements.value.findIndex(el => el.id === selectedNode.value.id)
|
||||
if (idx !== -1) {
|
||||
elements.value[idx].data = { ...selectedNode.value.data }
|
||||
elements.value[idx].style = { ...selectedNode.value.style }
|
||||
elements.value[idx].type = selectedNode.value.type
|
||||
elements.value.splice(idx, 1, { ...elements.value[idx] })
|
||||
}
|
||||
// Track changes
|
||||
unsavedChanges.trackChanges(transformToObsidianCanvas(elements.value))
|
||||
trackChanges(transformToObsidianCanvas(elements.value))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -357,15 +398,25 @@ const onNodeDragStop = (event) => {
|
||||
}
|
||||
|
||||
// Track changes
|
||||
unsavedChanges.trackChanges(transformToObsidianCanvas(elements.value))
|
||||
trackChanges(transformToObsidianCanvas(elements.value))
|
||||
|
||||
console.log('Node drag stopped:', node.id, 'at position:', node.position)
|
||||
}
|
||||
|
||||
const onNodeDoubleClick = (event) => {
|
||||
const node = event.node
|
||||
const currentState = node.data?.state || 'pending'
|
||||
const nextState = currentState === 'pending' ? 'in_progress' : currentState === 'in_progress' ? 'completed' : 'pending'
|
||||
const currentState = node.data?.state || 'todo'
|
||||
|
||||
// Quick toggle workflow: todo -> in_progress -> review -> completed -> todo
|
||||
const nextStateMap = {
|
||||
'todo': 'in_progress',
|
||||
'in_progress': 'review',
|
||||
'review': 'completed',
|
||||
'completed': 'todo',
|
||||
'blocked': 'todo' // From blocked back to todo
|
||||
}
|
||||
|
||||
const nextState = nextStateMap[currentState] || 'todo'
|
||||
updateNodeData({ id: node.id, data: { state: nextState } })
|
||||
}
|
||||
|
||||
@@ -475,15 +526,20 @@ const manualGitSync = async () => {
|
||||
// Enhanced save function with Git sync
|
||||
const saveCanvas = async () => {
|
||||
try {
|
||||
console.log('[DEBUG saveCanvas] Starting save...')
|
||||
const obsidianCanvas = transformToObsidianCanvas(elements.value)
|
||||
await $fetch(`/api/canvas/${props.canvasId}`, {
|
||||
|
||||
console.log('[DEBUG saveCanvas] Sending PUT request...')
|
||||
const res = await $fetch(`/api/canvas/${props.canvasId}`, {
|
||||
method: 'PUT',
|
||||
body: obsidianCanvas,
|
||||
body: { ...obsidianCanvas, name: canvasName.value },
|
||||
headers: authHeaders.value
|
||||
})
|
||||
console.log('[DEBUG saveCanvas] PUT request successful!', res)
|
||||
|
||||
// Mark as saved
|
||||
unsavedChanges.markAsSaved(obsidianCanvas)
|
||||
markAsSaved(obsidianCanvas)
|
||||
console.log('[DEBUG saveCanvas] Marked as saved in UI')
|
||||
|
||||
// Auto-sync to Git if repository exists
|
||||
await gitSync.autoSyncCanvas(props.canvasId, obsidianCanvas)
|
||||
@@ -494,11 +550,25 @@ const saveCanvas = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const usersList = ref([])
|
||||
|
||||
// Load data on mount
|
||||
onMounted(async () => {
|
||||
await loadData()
|
||||
// Check for existing Git repository
|
||||
await gitSync.checkRepository(props.canvasId)
|
||||
|
||||
// Load users for assignee dropdown
|
||||
try {
|
||||
const res = await fetch('/api/users', {
|
||||
headers: authHeaders.value
|
||||
})
|
||||
if (res.ok) {
|
||||
usersList.value = await res.json()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load users', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
<div v-if="data.state" :class="stateBadgeClass">
|
||||
{{ stateLabel }}
|
||||
</div>
|
||||
<div v-if="data.priority" :class="priorityBadgeClass">
|
||||
{{ priorityLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content preview for measurement -->
|
||||
@@ -99,9 +102,15 @@ const nodeStyle = computed(() => {
|
||||
}
|
||||
|
||||
if (props.data.state === 'completed') {
|
||||
baseStyle.borderColor = '#10b981'
|
||||
baseStyle.borderColor = '#10b981' // Green
|
||||
} else if (props.data.state === 'in_progress') {
|
||||
baseStyle.borderColor = '#f59e0b'
|
||||
baseStyle.borderColor = '#3b82f6' // Blue
|
||||
} else if (props.data.state === 'review') {
|
||||
baseStyle.borderColor = '#a855f7' // Purple
|
||||
} else if (props.data.state === 'blocked') {
|
||||
baseStyle.borderColor = '#ef4444' // Red
|
||||
} else if (props.data.state === 'todo') {
|
||||
baseStyle.borderColor = '#64748b' // Slate
|
||||
}
|
||||
|
||||
return baseStyle
|
||||
@@ -124,22 +133,44 @@ const nodeIcon = computed(() => {
|
||||
const nodeStateClass = computed(() => {
|
||||
const state = props.data.state
|
||||
if (state === 'completed') return 'border-emerald-600 bg-emerald-950/20'
|
||||
if (state === 'in_progress') return 'border-yellow-600 bg-yellow-950/20'
|
||||
if (state === 'in_progress') return 'border-blue-600 bg-blue-950/20'
|
||||
if (state === 'review') return 'border-purple-600 bg-purple-950/20'
|
||||
if (state === 'blocked') return 'border-red-600 bg-red-950/20'
|
||||
if (state === 'todo') return 'border-slate-500 bg-slate-900/50'
|
||||
return ''
|
||||
})
|
||||
|
||||
const stateBadgeClass = computed(() => {
|
||||
const state = props.data.state
|
||||
if (state === 'completed') return 'px-1.5 py-0.5 bg-emerald-800/50 text-emerald-300 rounded text-[10px] font-medium'
|
||||
if (state === 'in_progress') return 'px-1.5 py-0.5 bg-yellow-800/50 text-yellow-300 rounded text-[10px] font-medium'
|
||||
return 'px-1.5 py-0.5 bg-slate-700 text-slate-400 rounded text-[10px] font-medium'
|
||||
if (state === 'in_progress') return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium'
|
||||
if (state === 'review') return 'px-1.5 py-0.5 bg-purple-800/50 text-purple-300 rounded text-[10px] font-medium'
|
||||
if (state === 'blocked') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-medium'
|
||||
return 'px-1.5 py-0.5 bg-slate-700 text-slate-300 rounded text-[10px] font-medium'
|
||||
})
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
const state = props.data.state
|
||||
if (state === 'completed') return '✓'
|
||||
if (state === 'in_progress') return '⟳'
|
||||
return '○'
|
||||
if (state === 'completed') return '✅ Done'
|
||||
if (state === 'in_progress') return '⏳ In Progress'
|
||||
if (state === 'review') return '👀 Review'
|
||||
if (state === 'blocked') return '⛔ Blocked'
|
||||
return '📋 To Do'
|
||||
})
|
||||
|
||||
// Priority handling
|
||||
const priorityBadgeClass = computed(() => {
|
||||
const prio = props.data.priority
|
||||
if (prio === 'high') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-bold'
|
||||
if (prio === 'low') return 'px-1.5 py-0.5 bg-slate-800/50 text-slate-400 rounded text-[10px] font-medium'
|
||||
return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium' // medium
|
||||
})
|
||||
|
||||
const priorityLabel = computed(() => {
|
||||
const prio = props.data.priority
|
||||
if (prio === 'high') return '🔥 High'
|
||||
if (prio === 'low') return '🧊 Low'
|
||||
return '⚡ Med'
|
||||
})
|
||||
|
||||
// Get node title from text content
|
||||
|
||||
@@ -47,9 +47,20 @@ export const useCanvas = () => {
|
||||
width: n.width || 300,
|
||||
height: n.height || 200,
|
||||
text: n.text,
|
||||
label: n.label,
|
||||
file: n.file,
|
||||
frontmatter: n.frontmatter,
|
||||
state: n.state || 'pending',
|
||||
priority: n.priority,
|
||||
assigned_to: n.assigned_to,
|
||||
alarm: n.alarm,
|
||||
script_id: n.script_id,
|
||||
prompt: n.prompt,
|
||||
url: n.url,
|
||||
method: n.method,
|
||||
repo: n.repo,
|
||||
branch: n.branch,
|
||||
message: n.message,
|
||||
style: n.style || {}
|
||||
}))
|
||||
|
||||
|
||||
@@ -10,8 +10,13 @@ export const useUnsavedChanges = () => {
|
||||
|
||||
if (lastSavedData.value && currentData.value !== lastSavedData.value) {
|
||||
isDirty.value = true
|
||||
console.log('[DEBUG Unsaved] Changed to dirty because data is different')
|
||||
} else if (!lastSavedData.value) {
|
||||
isDirty.value = true
|
||||
console.log('[DEBUG Unsaved] Changed to dirty because no lastSavedData')
|
||||
} else {
|
||||
isDirty.value = false
|
||||
console.log('[DEBUG Unsaved] Not dirty, data is identical')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +25,7 @@ export const useUnsavedChanges = () => {
|
||||
lastSavedData.value = JSON.stringify(data)
|
||||
currentData.value = lastSavedData.value
|
||||
isDirty.value = false
|
||||
console.log('[DEBUG Unsaved] Marked as saved, isDirty = false')
|
||||
}
|
||||
|
||||
// Reset dirty state
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
<div class="flex items-center justify-between h-14">
|
||||
<div class="flex items-center gap-4">
|
||||
<NuxtLink to="/" class="text-xl font-bold text-blue-400">XFlow</NuxtLink>
|
||||
<NuxtLink to="/canvases" class="text-sm text-slate-400 hover:text-white transition">Карты</NuxtLink>
|
||||
<NuxtLink to="/canvas" class="text-sm text-slate-400 hover:text-white transition">Редактор</NuxtLink>
|
||||
<NuxtLink to="/flow" class="text-sm text-slate-400 hover:text-white transition">Поток</NuxtLink>
|
||||
<NuxtLink to="/canvases" class="text-sm text-slate-400 hover:text-white transition">Проекты</NuxtLink>
|
||||
<NuxtLink to="/tasks" class="text-sm text-slate-400 hover:text-white transition">Задачи</NuxtLink>
|
||||
<NuxtLink v-if="isAdmin" to="/admin" class="text-sm text-slate-400 hover:text-white transition">Админ</NuxtLink>
|
||||
<NuxtLink to="/settings" class="text-sm text-slate-400 hover:text-white transition">Настройки</NuxtLink>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<div
|
||||
v-for="task in getTasksForColumn(col.id)"
|
||||
:key="task.id"
|
||||
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>
|
||||
|
||||
<div v-if="getTasksForColumn(col.id).length === 0" class="text-center py-6 border-2 border-dashed border-slate-700 rounded-lg">
|
||||
<span class="text-slate-500 text-sm">Нет задач</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
|
||||
const auth = useAuth()
|
||||
const loading = ref(true)
|
||||
const tasks = ref([])
|
||||
|
||||
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 || []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load tasks', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 fetchTasks()
|
||||
})
|
||||
</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>
|
||||
@@ -10,7 +10,12 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const existing = await prisma.canvas.findUnique({ where: { id } })
|
||||
if (!existing) throw createError({ statusCode: 404, message: 'Canvas not found' })
|
||||
if (existing.ownerId !== user.id) throw createError({ statusCode: 403, message: 'Access denied' })
|
||||
|
||||
// Allow if owner, or if admin, or if canvas is public (for collaborative editing)
|
||||
if (existing.ownerId !== user.id && user.role !== 'admin' && !existing.isPublic) {
|
||||
console.log('[DEBUG 403]', { ownerId: existing.ownerId, userId: user.id, userRole: user.role, isPublic: existing.isPublic })
|
||||
throw createError({ statusCode: 403, message: 'Access denied' })
|
||||
}
|
||||
|
||||
const { name, nodes, edges, isPublic, groupId } = body
|
||||
const canvas = await prisma.canvas.update({
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = requireAuth(event)
|
||||
|
||||
const userGroups = await prisma.groupMember.findMany({
|
||||
where: { userId: user.id },
|
||||
select: { groupId: true }
|
||||
})
|
||||
const groupIds = userGroups.map(m => m.groupId)
|
||||
|
||||
// Get all canvases user has access to
|
||||
const canvasList = await prisma.canvas.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: user.id },
|
||||
{ isPublic: true },
|
||||
{ groupId: { in: groupIds } }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
nodes: true
|
||||
}
|
||||
})
|
||||
|
||||
// Extract tasks from all canvases
|
||||
const allTasks = []
|
||||
|
||||
for (const canvas of canvasList) {
|
||||
const nodes = Array.isArray(canvas.nodes) ? canvas.nodes :
|
||||
(typeof canvas.nodes === 'string' ? JSON.parse(canvas.nodes) : [])
|
||||
|
||||
if (!Array.isArray(nodes)) continue
|
||||
|
||||
const canvasTasks = nodes.filter(n => {
|
||||
const type = n.type || n.data?.type
|
||||
if (type !== 'task') return false
|
||||
|
||||
// Temporary: Show ALL tasks regardless of assignee so you can see them!
|
||||
return true
|
||||
})
|
||||
|
||||
for (const t of canvasTasks) {
|
||||
allTasks.push({
|
||||
...t,
|
||||
canvasId: canvas.id,
|
||||
canvasName: canvas.name
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { tasks: allTasks }
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { defineEventHandler } from 'h3'
|
||||
import { prisma } from '~/server/utils/prisma'
|
||||
import { requireAuth } from '~/server/utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireAuth(event)
|
||||
if (!user) return []
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
profile: {
|
||||
select: {
|
||||
displayName: true,
|
||||
avatar: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return users.map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
role: u.role,
|
||||
displayName: u.profile?.displayName || u.username,
|
||||
avatar: u.profile?.avatar
|
||||
}))
|
||||
})
|
||||
Reference in New Issue
Block a user