Newer
Older
gnexus-tasks / frontend / src / views / TreeView.vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { api, type Project, type Task } from '../api'
import { formatMinutes, formatMoney, 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)
  }
}
</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">
            бюджет {{ formatMoney(row.task.budget_money) }}
            <template v-if="row.task.cost_estimate_money !== null">
              / оценка {{ formatMoney(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-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>