<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { api, type Project, type Task } from '../api'
import { formatMinutes, statusLabel, statusVariant } from '../taskui'
defineOptions({ name: 'TreeView' })
// Дерево задач (основной вид, ТЗ 3.6): подзадачи — ветви произвольной вложенности.
// Сырые задачи живут в стеке; дерево показывает утверждённые.
interface FlatNode {
task: Task
depth: number
hasChildren: boolean
}
const tasks = ref<Task[]>([])
const projects = ref<Project[]>([])
const error = ref('')
const loading = ref(false)
const expanded = ref<Record<number, boolean>>({})
async function load() {
loading.value = true
error.value = ''
try {
tasks.value = await api.listTasks({ detail_state: 'approved' })
} catch (e) {
error.value = String(e)
} finally {
loading.value = false
}
}
onMounted(async () => {
await load()
projects.value = await api.listProjects()
})
// Плоское представление дерева с отступами (проще, чем рекурсивный компонент)
const rows = computed<FlatNode[]>(() => {
const byId = new Map<number, Task[]>([])
tasks.value.forEach((t) => {
const key = t.parent_task_id ?? 0
const list = byId.get(key) ?? []
list.push(t)
byId.set(key, list)
})
const out: FlatNode[] = []
const visit = (parentId: number, depth: number) => {
const children = byId.get(parentId) ?? []
children.forEach((t) => {
const kids = byId.get(t.id) ?? []
out.push({ task: t, depth, hasChildren: kids.length > 0 })
if (kids.length > 0 && (expanded.value[t.id] ?? true)) visit(t.id, depth + 1)
})
}
visit(0, 0)
return out
})
function toggle(task: Task) {
expanded.value[task.id] = !(expanded.value[task.id] ?? true)
}
async function setStatus(task: Task, status: string) {
error.value = ''
try {
await api.updateTask(task.id, { status })
await load()
} catch (e) {
error.value = String(e)
}
}
async function addSubtask(task: Task) {
const title = window.prompt('Подзадача:')
if (!title) return
error.value = ''
try {
await api.createTask(title.trim(), '', task.id)
// подзадача попадёт в стек; в дереве появится после утверждения
expanded.value[task.id] = true
await load()
} catch (e) {
error.value = String(e)
}
}
async function remove(task: Task) {
error.value = ''
try {
await api.deleteTask(task.id)
await load()
} catch (e) {
error.value = String(e)
}
}
// Прогноз из истории похожих завершённых задач (ТЗ 3.4)
async function predict(task: Task) {
error.value = ''
try {
await api.predictTask(task.id)
await load()
} catch (e) {
error.value = String(e)
}
}
</script>
<template>
<section>
<GnPageHeader kicker="gntodo" title="Дерево задач">
<template #meta>Задач: {{ tasks.length }}</template>
</GnPageHeader>
<GnAlert v-if="error" variant="error">{{ error }}</GnAlert>
<GnEmptyState
v-if="!loading && rows.length === 0"
icon="ph-tree-structure"
title="Задач пока нет"
text="Утвердите задачи в стеке — здесь вырастет дерево."
/>
<div class="tree">
<div v-for="row in rows" :key="row.task.id" class="node" :style="{ marginLeft: row.depth * 24 + 'px' }">
<div class="node-row">
<GnIconButton
v-if="row.hasChildren"
:icon="expanded[row.task.id] ?? true ? 'ph-caret-down' : 'ph-caret-right'"
:label="expanded[row.task.id] ?? true ? 'Свернуть' : 'Развернуть'"
@click="toggle(row.task)"
/>
<span v-else class="leaf-space" />
<span class="title" :class="{ done: row.task.status === 'done' }">{{ row.task.title }}</span>
<GnBadge :variant="statusVariant(row.task.status)">{{ statusLabel(row.task.status) }}</GnBadge>
<GnBadge v-if="row.task.project" variant="neutral" icon="ph-folder">
{{ row.task.project.name }}
</GnBadge>
<GnBadge v-if="row.task.priority !== null" variant="warning">
P{{ row.task.priority }}
</GnBadge>
<GnBadge v-if="row.task.estimated_minutes" variant="neutral">
≈ {{ formatMinutes(row.task.estimated_minutes) }}
</GnBadge>
<GnBadge v-if="row.task.budget_money !== null" variant="neutral">
бюджет {{ row.task.budget_money }} ₽
<template v-if="row.task.cost_estimate_money !== null">
/ оценка {{ row.task.cost_estimate_money }} ₽
</template>
</GnBadge>
<GnBadge v-for="tag in row.task.tags" :key="tag.id" variant="neutral">{{ tag.name }}</GnBadge>
<span class="spacer" />
<GnSelect
class="status-select"
:model-value="row.task.status"
:options="[
{ value: 'to_do', label: 'К выполнению' },
{ value: 'in_progress', label: 'В работе' },
{ value: 'done', label: 'Завершено' },
{ value: 'cancelled', label: 'Отменено' },
{ value: 'deferred', label: 'Отложено' },
]"
@update:model-value="(s: string) => setStatus(row.task, s)"
/>
<GnIconButton icon="ph-plus" label="Добавить подзадачу" @click="addSubtask(row.task)" />
<GnIconButton
icon="ph-hourglass-medium"
label="Пересчитать прогноз времени"
@click="predict(row.task)"
/>
<GnIconButton icon="ph-trash" label="Удалить задачу" @click="remove(row.task)" />
</div>
</div>
</div>
</section>
</template>
<style scoped>
.tree {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.node-row {
display: flex;
gap: 0.4rem;
align-items: center;
border: 1px solid var(--border, #333);
border-radius: 8px;
padding: 0.4rem 0.6rem;
background: var(--surface, transparent);
}
.leaf-space {
width: 2rem;
flex: none;
}
.node-row .title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-row .title.done {
text-decoration: line-through;
color: var(--text-muted, #888);
}
.spacer {
flex: 1;
}
.status-select {
min-width: 9rem;
}
</style>