Newer
Older
gnexus-tasks / frontend / src / views / ListView.vue
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Project, type Tag, type Task } from '../api'
import { celebrateEarned } from '../gamification'
import { useRealtimeRefresh } from '../realtime'
import { statusOptions, priorityOptions } from '../taskui'
import TaskCard from '../components/TaskCard.vue'
import Pagination from '../components/Pagination.vue'

// Классический вид (ТЗ 3.6): список с фильтрами — проект, тег, статус, приоритет.
// Фильтры применяются сразу при смене значения.

const { t } = useI18n()

const tasks = ref<Task[]>([])
const projects = ref<Project[]>([])
const tags = ref<Tag[]>([])
const error = ref('')
const loading = ref(false)

const filters = ref({
  status: 'to_do',
  project_id: '',
  tag_id: '',
  priority: '',
})

// Пагинация: по 30 карточек на страницу, смена фильтра — с первой
const page = ref(1)
const pagedTasks = computed(() =>
  tasks.value.slice((page.value - 1) * 30, page.value * 30),
)
watch(
  filters,
  () => {
    page.value = 1
  },
  // селекты мутируют вложенные поля — без deep watch молчит (ТЗ 3.6: смена
  // фильтра возвращает на первую страницу)
  { deep: true },
)

// Токен запроса: поздний ответ старого фильтра не перетирает список нового
let loadSeq = 0

async function load() {
  const seq = ++loadSeq
  loading.value = true
  error.value = ''
  try {
    const fetched = await api.listTasks({
      detail_state: 'approved',
      status: filters.value.status,
      project_id: filters.value.project_id,
      tag_id: filters.value.tag_id,
      priority: filters.value.priority,
    })
    if (seq !== loadSeq) return
    tasks.value = fetched
  } catch (e) {
    if (seq !== loadSeq) return
    error.value = String(e)
  } finally {
    if (seq === loadSeq) loading.value = false
  }
}

onMounted(async () => {
  await load()
  try {
    // опции фильтров: грузятся вместе со списком (не в try load) — но и они
    // должны показывать ошибку, а не падать молча
    projects.value = await api.listProjects()
    tags.value = await api.listTags()
  } catch (e) {
    error.value = String(e)
  }
})

// Реактивность (ТЗ 3.14): изменения задач/проектов (MCP, соседняя вкладка) —
// перечитываем список с текущими фильтрами
useRealtimeRefresh(load, ['task.changed', 'task.deleted', 'project.changed', 'project.deleted'])

async function setStatus(task: Task, status: string) {
  error.value = ''
  try {
    await api.updateTask(task.id, { status })
    celebrateEarned()
    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)
  }
}

// Опции фильтра: «любой» + все статусы
const statusOptionsWithAny = computed(() => [
  { value: '', label: t('common.anyStatus') },
  ...statusOptions(),
])
// Градации приоритета (ТЗ 3.6): без приоритета задача считается very_low
const priorityOptionsWithAny = computed(() => [
  { value: '', label: t('common.any') },
  ...priorityOptions(),
])
</script>

<template>
  <section>
    <GnPageHeader kicker="GNexus Tasks" :title="t('list.title')">
      <template #meta>{{ t('list.count', { n: tasks.length }) }}</template>
      <!-- Спиннер обновления: данные уже есть, список дотягивается -->
      <template #actions>
        <GnLoader
          v-if="loading && tasks.length"
          circle
          label=""
          :aria-label="t('common.loading')"
          class="refresh-loader"
        />
      </template>
    </GnPageHeader>

    <!-- Фильтры в одну строку (переносятся на мобильных); применяются сразу -->
    <GnToolbar class="filters">
      <GnSelect
        v-model="filters.status"
        class="filter-select"
        :options="statusOptionsWithAny"
        @update:model-value="load"
      />
      <GnSelect
        v-model="filters.project_id"
        class="filter-select"
        icon="ph-folder"
        :options="[
          { value: '', label: t('common.any') },
          ...projects.map((p) => ({ value: String(p.id), label: p.name })),
        ]"
        @update:model-value="load"
      />
      <GnSelect
        v-model="filters.tag_id"
        class="filter-select"
        icon="ph-tag"
        :options="[
          { value: '', label: t('common.any') },
          ...tags.map((tg) => ({ value: String(tg.id), label: tg.name })),
        ]"
        @update:model-value="load"
      />
      <GnSelect
        v-model="filters.priority"
        class="filter-select"
        icon="ph-flag"
        :aria-label="t('list.filterPriority')"
        :options="priorityOptionsWithAny"
        @update:model-value="load"
      />
    </GnToolbar>

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

    <!-- Скелетон карточек задач: заголовок + ряд бейджей, как в TaskCard -->
    <div v-if="loading" class="list-skeleton" aria-hidden="true">
      <div v-for="i in 4" :key="i" class="card">
        <div class="card-content">
          <span class="skeleton skeleton-title" />
          <div class="sk-badges">
            <span v-for="j in 3" :key="j" class="skeleton skeleton-square" />
          </div>
        </div>
      </div>
    </div>

    <GnEmptyState
      v-if="!loading && tasks.length === 0"
      icon="ph-magnifying-glass"
      :title="t('list.emptyTitle')"
      :text="t('list.emptyText')"
    />

    <div class="task-list">
      <TaskCard
        v-for="task in pagedTasks"
        :key="task.id"
        :task="task"
        show-project
        @status-change="setStatus"
        @delete="askDelete"
      />
    </div>

    <Pagination v-model:page="page" :total="tasks.length" />

    <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>
.filters {
  margin-bottom: 1.5rem;
  /* без подложки тулбара кита: рамка, фон и отступы не нужны */
  padding: 0;
  border: none;
  background: none;
}
/* GnSelect рендерит div.form-group > label > div.select-wrap > select — сжимаем обёртки */
.filters :deep(.form-group) {
  width: auto;
  margin-bottom: 0;
}
.filters :deep(.select-wrap) {
  width: 14rem;
  margin-top: 0;
}
.list-skeleton {
  margin-bottom: 1rem;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.75rem;
}
/* карточка-скелет: как реальная карточка задачи — тянется на колонку */
.list-skeleton .card {
  width: 100%;
  max-width: none;
}
.list-skeleton .card-content > .skeleton-title {
  display: block;
  margin-bottom: 10px;
}
.sk-badges {
  display: flex;
  gap: 6px;
}
.task-list {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}
/* на десктопе — карточки в две колонки, одной высоты в ряду.
   minmax(0,1fr): трек 1fr не сжимается ниже min-content — длинные слова
   в карточке распирают колонку за пределы контейнера (проверенный приём
   мобильных фиксов) */
@media (min-width: 900px) {
  .task-list {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  .task-list :deep(.task-card) {
    height: 100%;
  }
}
</style>