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

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

const { t } = useI18n()

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

// Фильтра по проекту здесь нет (0.73): разрез по проектам — это раздел
// «Проекты», а в общем списке такой фильтр только дублировал его
const filters = ref({
  status: 'to_do',
  tag: '',
  // Приоритет — переключателем (0.74): пять градаций в общем списке почти не
  // нужны, а нужен один вопрос «показать главное». Порог — градация high
  // (шкала: medium 5-6, high 7-8, urgent 9-10)
  prioritized: false,
  // Регулярные (0.91, ТЗ 3.5): второй переключатель-фильтр — «показать только
  // то, что повторяется». Раньше регулярную в общем списке было не отличить от
  // разовой задачи на сегодня
  recurring: false,
  // Кто создал (0.85, ТЗ 3.20): '' все | 'user' я | 'agent' агент. Пометку ставит
  // бэкенд по актору — агенту нельзя выдать себя за владельца, но и наоборот
  created: '' as '' | 'user' | 'agent',
})

// Пагинация: по 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

// Поиск по тегу (0.70) спрашивает сервер по подстроке: без задержки запрос уходил
// бы на каждую букву. Остальные фильтры (табы статуса, переключатель приоритета)
// грузят сразу — они меняются одним кликом.
// Пустой ввод — это в том числе сброс крестиком (0.71): его применяем сразу,
// задержка тут только мешала бы
let tagTimer: number | undefined
watch(
  () => filters.value.tag,
  (value) => {
    window.clearTimeout(tagTimer)
    if (!value) {
      void load()
      return
    }
    tagTimer = window.setTimeout(() => void load(), 250)
  },
)
onBeforeUnmount(() => window.clearTimeout(tagTimer))

async function load() {
  const seq = ++loadSeq
  loading.value = true
  error.value = ''
  // «Ждёт приёмки» (0.85) — не статус, а плоскость приёмки (ТЗ 3.20): в табах
  // это отдельная вкладка, но серверу уходит accept_state, а не status
  const review = filters.value.status === REVIEW_TAB
  try {
    const fetched = await api.listTasks({
      detail_state: 'approved',
      status: review ? '' : filters.value.status,
      accept_state: review ? 'pending' : '',
      // тег ищется по подстроке имени (0.70) — фильтрует сервер
      tag: filters.value.tag,
      // «Приоритетные» (0.74) — отсечка по нижней границе шкалы: порог берём из
      // taskui (gradeToPriority), чтобы он не расходился с градациями
      priority_min: filters.value.prioritized ? gradeToPriority('high') : '',
      created_by_kind: filters.value.created,
      // «Регулярные» (0.91): тип задачи — серверный разрез, как и остальные
      // фильтры списка. Пустая строка = фильтра нет (api.listTasks пропускает '')
      task_type: filters.value.recurring ? 'recurring' : '',
    })
    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) — но и они
    // должны показывать ошибку, а не падать молча
    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)
  }
}

// Фильтр статуса — табы (0.70): пять статусов из выпадашки не видны, а так
// состояние списка читается с одного взгляда. id «» = «Все» — пустой фильтр.
// Шестая вкладка — «Ждёт приёмки» (0.85): у неё свой серверный параметр
const REVIEW_TAB = 'review'

const statusTabs = computed(() => [
  { id: '', label: t('common.all'), icon: 'ph-list-bullets' },
  ...statusOptions().map((o) => ({ id: o.value, label: o.label, icon: statusIcon(o.value) })),
  { id: REVIEW_TAB, label: t('list.tabReview'), icon: 'ph-hourglass-medium' },
])

// «Создано» (0.85): все/я/агент — фильтр по автору задачи, а не по исполнителю
const createdOptions = computed(() => [
  { value: '', label: t('list.createdAll') },
  { value: 'user', label: t('list.createdMe') },
  { value: 'agent', label: t('list.createdAgent') },
])
// Переключателя приоритета в табах больше нет (0.74): из шести пунктов
// пользователю нужен был один — «приоритетные». Оставлен GnSwitch.
// Опции поиска тега: полный справочник, подсказки GnCombobox фильтрует сам
const tagOptions = computed(() => tags.value.map((tg) => ({ value: String(tg.id), label: tg.name })))
// Текст поиска тега без пробелов: по нему решаем, что показать в подсказках
// (совпавшие теги или подсказку «начните вводить») и нужен ли крестик сброса
const tagQuery = computed(() => filters.value.tag.trim())

// Сброс фильтра по тегу: watcher выше применит пустой ввод сразу, без debounce
function clearTag() {
  filters.value.tag = ''
}
// Подпись у поля тега снята (0.73): фильтр в строке остался один — и фильтру
// статуса, и фильтру приоритета подписи не нужны, табы подписаны собой
</script>

<template>
  <section>
    <GnPageHeader kicker="GNexus Tasks" :title="t('list.title')">
      <template #meta>{{ t('list.count', { n: tasks.length }) }}</template>
      <!-- Фильтры-поля собраны в шапке (0.75): переключатель «Приоритетные» и поиск
           по тегу стоят справа от заголовка одной группой. Отдельной строкой под
           шапкой остались только табы статуса -->
      <template #actions>
        <div class="header-filters">
          <!-- Кто создал (0.85, ТЗ 3.20): агенту — свои задачи отдельно от
               выданных владельцем. Селект, а не табы: вопрос редкий, а табы
               заняты статусами -->
          <div class="filter-created">
            <GnSelect
              :model-value="filters.created"
              :options="createdOptions"
              icon="ph-sparkle"
              :aria-label="t('list.filterCreated')"
              @update:model-value="
                (v: string) => {
                  filters.created = v as '' | 'user' | 'agent'
                  load()
                }
              "
            />
          </div>
          <!-- «Приоритетные» — переключатель кита (0.74). Задача без приоритета
               считается самой низкой (very_low), поэтому в отсечку не попадает -->
          <GnSwitch
            :model-value="filters.prioritized"
            :label="t('list.filterPrioritized')"
            @update:model-value="
              (v: boolean) => {
                filters.prioritized = v
                load()
              }
            "
          />
          <!-- «Регулярные» (0.91, ТЗ 3.5) — переключатель рядом с «Приоритетными»:
               два вопроса об одном списке («что важное» и «что повторяется»)
               читаются парой. Фильтрует по типу задачи, разовых не показывает -->
          <GnSwitch
            :model-value="filters.recurring"
            :label="t('list.filterRecurring')"
            @update:model-value="
              (v: boolean) => {
                filters.recurring = v
                load()
              }
            "
          />
          <!-- Тег — поиск по имени (0.70, доработано 0.71): селект из десятков тегов
               не помогал найти нужный; ввод фильтрует список сразу, подсказки — сам
               GnCombobox. Подсказки даём только под набранное, иначе на фокусе
               выпадала простыня из всех тегов; обёртка задаёт ширину — class у кита
               уходит на <input>, не на form-group -->
          <div class="filter-tag">
            <GnCombobox
              v-model="filters.tag"
              icon="ph-tag"
              :options="tagQuery ? tagOptions : []"
              :placeholder="t('list.filterTagSearch')"
              :aria-label="t('list.filterTag')"
              :not-found-text="tagQuery ? t('list.noTagsFound') : t('list.filterTagHint')"
            />
            <!-- Сброс: у поиска нет пункта «— любой —», без крестика фильтр не снять -->
            <GnIconButton
              v-if="tagQuery"
              class="filter-tag-clear"
              icon="ph-x"
              size="sm"
              :label="t('list.filterTagClear')"
              @click="clearTag"
            />
          </div>
        </div>
        <!-- Спиннер обновления: данные уже есть, список дотягивается -->
        <GnLoader
          v-if="loading && tasks.length"
          circle
          label=""
          :aria-label="t('common.loading')"
          class="refresh-loader"
        />
      </template>
    </GnPageHeader>

    <!-- Статус — табами (0.70): на узком экране табы прокручиваются по горизонтали
         (кит: .tabs-list с overflow-x), страница по горизонтали не едет.
         Приоритет табами был только в 0.73 и заменён переключателем (0.74) -->
    <GnTabs
      :model-value="filters.status"
      :items="statusTabs"
      compact
      class="filter-tabs"
      :aria-label="t('list.filterStatus')"
      @update:model-value="
        (v: string) => {
          filters.status = v
          load()
        }
      "
    />

    <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"
        @moved="load"
      />
    </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>
/* Табы фильтра статуса (0.70): class ложится на корень GnTabs, поэтому без
   :deep. Кит тянет табы на width:100%, а компактные — на max-width:620px:
   шестой статус уезжал за край на десктопе. Возвращаем содержимому полную ширину.
   Отступ снизу (0.78): 0.6rem было мало — табы липли к первой карточке списка,
   отрываем их на 1.25rem (ровно тот же шаг, что у сетки задачи) */
.filter-tabs {
  width: auto;
  max-width: 100%;
  margin-bottom: 1.25rem;
}
/* Фильтры в шапке (0.75): переключатель и поле тега — одной группой справа от
   заголовка. Свой gap вместо китовских 8px у .page-header-actions: подпись
   переключателя липла к коробке поля. Группа переносится по строкам на узком
   экране сама (flex-wrap) */
.header-filters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  align-items: center;
}
/* Переключатель сдвинут на 3px вправо (0.80): у кита бегунок — квадрат 18×18
   в `left:-5px` от коробки контрола, поэтому видимый левый край свитча на 3-5px
   левее соседей по ряду (та же коробка поля тега стоит ровно). Это компенсация
   выступа, а не отступ между элементами */
.header-filters :deep(.switch) {
  margin-left: 3px;
}
/* Поле тега: ширину задаёт обёртка, потому что class из GnCombobox уходит на
   <input>, а не на form-group. 300px — как на странице проекта (0.71): в 224px
   имя тега не читалось */
.filter-tag {
  position: relative;
  width: 300px;
}
/* Фильтр «Создано» (0.85): подпись не нужна — вариант читается сам, а class из
   GnSelect уходит на <select>, не на form-group, поэтому ширину задаёт обёртка
   и заодно снимает китовский запас под полем (как у поля тега) */
.filter-created {
  width: 170px;
}
.filter-created :deep(.form-group) {
  width: 100%;
  min-width: 0;
  margin-bottom: 0;
}
.filter-created :deep(.label) {
  margin-bottom: 0;
}.filter-tag :deep(.form-group) {
  width: 100%;
  min-width: 0;
  /* Обёртка должна совпадать с коробкой поля (0.76). У кита под полем остаётся
     margin-bottom 15px у form-group, а над полем — 8px у input: из-за них поле
     стояло на 3.5px выше центра ряда и не сходилось по вертикали с переключателем */
  margin-bottom: 0;
}
.filter-tag :deep(.label) {
  margin-bottom: 0;
}
.filter-tag :deep(input.input) {
  margin-top: 0;
}
/* Иконка тега в поле — китовый ярус (bottom:1px, height:46px) в коробке поля
   на пиксель ниже её центра, а глиф кажется ниже ещё на пару: поднимаем на 3px */
.filter-tag :deep(.label > .ph) {
  transform: translateY(-3px);
}
/* Крестик сброса — внутри поля (0.71): своей кнопкой в ряду он не вписывался,
   а слот внутрь GnCombobox кит не даёт, поэтому кладём его поверх поля.
   Обёртка теперь ровно в коробку поля (50px, см. .form-group выше), значит кнопку
   центрируем в этих 50px: 16px отсчитывались от обёртки с 15px запаса под полем
   и кнопка уезжала к нижнему краю (0.76) */
.filter-tag-clear {
  position: absolute;
  right: 4px;
  bottom: calc((50px - 28px) / 2);
}
/* текст поля не заезжает под крестик — отступ только когда крестик есть */
.filter-tag:has(.filter-tag-clear) :deep(input.input) {
  padding-right: 2.4rem;
}
.list-skeleton {
  margin-bottom: 1rem;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0.75rem;
}
/* на широком экране скелетон повторяет сетку списка (три в ряд) — загрузка
   не дёргает раскладку */
@media (min-width: 1200px) {
  .list-skeleton {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}
/* карточка-скелет: как реальная карточка задачи — тянется на колонку */
.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%;
  }
}
/* с широким контейнером (0.68) — по три карточки в ряд; порог тот же, что у
   проектов, чтобы ряды на соседних страницах совпадали по ширине */
@media (min-width: 1200px) {
  .task-list {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}
</style>