<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 } 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: '',
})
// Пагинация: по 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,
})
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(),
])
</script>
<template>
<section>
<GnPageHeader kicker="GNexus Tasks" :title="t('list.title')">
<template #meta>{{ t('list.count', { n: tasks.length }) }}</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"
/>
</GnToolbar>
<GnAlert v-if="error" variant="error">{{ error }}</GnAlert>
<GnSkeleton v-if="loading" type="block" stack :count="3" class="list-skeleton" />
<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;
}
.task-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
/* на десктопе — карточки в две колонки, одной высоты в ряду */
@media (min-width: 900px) {
.task-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
.task-list :deep(.task-card) {
height: 100%;
}
}
</style>