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 { statusOptions } from '../taskui'
import QuickPrompt from '../components/QuickPrompt.vue'
import TaskBadges from '../components/TaskBadges.vue'

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)
  }
}

// Быстрое добавление подзадачи — модальное окно вместо window.prompt
const subtaskFor = ref<Task | null>(null)
const subtaskOpen = ref(false)

function openSubtaskPrompt(task: Task) {
  subtaskFor.value = task
  subtaskOpen.value = true
}

async function createSubtask(title: string) {
  if (!subtaskFor.value) return
  error.value = ''
  try {
    await api.createTask(title, '', subtaskFor.value.id)
    // подзадача попадёт в стек; в дереве появится после утверждения
    expanded.value[subtaskFor.value.id] = true
    await load()
  } catch (e) {
    error.value = String(e)
  }
}

// Удаление — только через подтверждение (действие необратимо)
const deleteTarget = ref<Task | null>(null)
const confirmOpen = ref(false)

function askDelete(task: Task) {
  deleteTarget.value = task
  confirmOpen.value = true
}

async function remove() {
  const task = deleteTarget.value
  if (!task) return
  confirmOpen.value = false
  error.value = ''
  try {
    await api.deleteTask(task.id)
    await load()
  } catch (e) {
    error.value = String(e)
  }
}

// Меню «⋯» в строке: смена статуса (отмечен текущий), подзадача, удаление
function taskMenu(task: Task) {
  return [
    ...statusOptions().map((o) => ({
      label: o.label,
      icon: o.value === task.status ? 'ph-check' : undefined,
      onSelect: () => setStatus(task, o.value),
    })),
    { label: t('tree.addSubtask'), icon: 'ph-plus', onSelect: () => openSubtaskPrompt(task) },
    { label: t('stack.deleteTask'), icon: 'ph-trash', danger: true, onSelect: () => askDelete(task) },
  ]
}
</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>

    <GnSkeleton v-if="loading" type="line" stack :count="6" class="tree-skeleton" />

    <GnEmptyState
      v-if="!loading && rows.length === 0"
      icon="ph-tree-structure"
      :title="t('tree.emptyTitle')"
      :text="t('tree.emptyText')"
    >
      <template #actions>
        <GnButton variant="primary" icon="ph-inbox" @click="$router.push('/stack')">
          {{ t('nav.stack') }}
        </GnButton>
      </template>
    </GnEmptyState>

    <div class="tree">
      <div
        v-for="row in rows"
        :key="row.task.id"
        class="node"
        :style="{ marginLeft: row.depth * 24 + 'px' }"
      >
        <div class="node-main">
          <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')"
            size="sm"
            @click="toggle(row.task)"
          />
          <span v-else class="leaf-space" />
          <span class="title" :class="{ done: row.task.status === 'done' }">{{ row.task.title }}</span>
          <GnDropdown class="node-menu" :items="taskMenu(row.task)">
            <template #trigger="{ toggle }">
              <GnIconButton icon="ph-dots-three-outline" :label="t('common.edit')" @click="toggle" />
            </template>
          </GnDropdown>
        </div>
        <div class="node-meta">
          <TaskBadges :task="row.task" />
        </div>
      </div>
    </div>

    <QuickPrompt
      v-model:open="subtaskOpen"
      :title="t('tree.promptSubtask')"
      @confirm="createSubtask"
    />

    <GnConfirmDialog
      v-model:open="confirmOpen"
      :title="t('common.confirmTitle')"
      :message="t('common.deleteConfirmTask', { title: deleteTarget?.title ?? '' })"
      :confirm-text="t('common.delete')"
      :cancel-text="t('common.cancel')"
      confirm-variant="danger"
      @confirm="remove"
    />
  </section>
</template>

<style scoped>
.tree {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}
.tree-skeleton {
  margin-bottom: 1rem;
}
.node {
  border: 1px solid var(--border, #333);
  border-radius: 8px;
  padding: 0.4rem 0.6rem;
  background: var(--surface, transparent);
}
.node-main {
  display: flex;
  gap: 0.4rem;
  align-items: center;
}
.node-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  align-items: center;
  margin-top: 0.35rem;
  /* выравнивание по заголовку (после каретки) */
  padding-left: 2.4rem;
}
.leaf-space {
  width: 2rem;
  flex: none;
}
.node-main .title {
  flex: 1;
  min-width: 0;
  font-weight: 500;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.node-main .title.done {
  text-decoration: line-through;
  color: var(--text-muted, #888);
}
.node-menu {
  flex: none;
}
/* метаданные переносятся на свою строку, мобильной вёрстке ничего не грозит */
@media (max-width: 480px) {
  .node {
    margin-left: 0 !important;
  }
  .node-meta {
    padding-left: 0;
  }
}
</style>