Newer
Older
gnexus-tasks / frontend / src / views / TreeView.vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Project, type Task } from '../api'
import {
  budgetBadge,
  deadlineLabel,
  deadlineOverdue,
  formatMinutes,
  recurrenceLabel,
  statusLabel,
  statusOptions,
  statusVariant,
} from '../taskui'

defineOptions({ name: 'TreeView' })

// Дерево задач (основной вид, ТЗ 3.6): подзадачи — ветви произвольной вложенности.
// Сырые задачи живут в стеке; дерево показывает утверждённые.

const { t } = useI18n()

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(t('tree.promptSubtask'))
  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="t('tree.title')">
      <template #meta>{{ t('tree.count', { n: tasks.length }) }}</template>
    </GnPageHeader>

    <GnAlert v-if="error" variant="error">{{ error }}</GnAlert>

    <GnEmptyState
      v-if="!loading && rows.length === 0"
      icon="ph-tree-structure"
      :title="t('tree.emptyTitle')"
      :text="t('tree.emptyText')"
    />

    <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 ? t('tree.collapse') : t('tree.expand')"
            @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.task_type === 'recurring'" variant="info" icon="ph-repeat">
            {{ recurrenceLabel(row.task) || t('taskui.recur.recurring') }}
          </GnBadge>
          <GnBadge
            v-if="deadlineLabel(row.task)"
            :variant="deadlineOverdue(row.task) ? 'error' : 'warning'"
            icon="ph-alarm"
          >
            {{ deadlineLabel(row.task) }}
          </GnBadge>
          <GnBadge v-if="row.task.budget_money !== null" variant="neutral">
            {{ budgetBadge(row.task) }}
          </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="statusOptions()"
            @update:model-value="(s: string) => setStatus(row.task, s)"
          />
          <GnIconButton icon="ph-plus" :label="t('tree.addSubtask')" @click="addSubtask(row.task)" />
          <GnIconButton icon="ph-trash" :label="t('stack.deleteTask')" @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>