Newer
Older
gnexus-tasks / frontend / src / views / StackView.vue
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Project, type Task } from '../api'
import { notifyCreated } from '../gamification'
import { useRealtimeRefresh } from '../realtime'
import { proposalItems } from '../taskui'
import { isMobile } from '../viewport'
import { useToast } from 'gnexus-ui-kit/vue'
import TaskCard from '../components/TaskCard.vue'
import TaskForm from '../components/TaskForm.vue'
import Dashboard from '../components/Dashboard.vue'
import Pagination from '../components/Pagination.vue'

// Главная страница: дашборд активных задач сверху + стек входящих ниже.
// LLM-предложение — черновик: «Да, всё верно» применяет его, иначе правим вручную
// (детализация — TaskForm в drawer'е, сохранение там же утверждает).

const { t } = useI18n()
const toast = useToast()

// Все задачи один раз: стек и дашборд — разные срезы одного списка
const tasks = ref<Task[]>([])
const projects = ref<Project[]>([])
const quickTitle = ref('')
const error = ref('')
const loading = ref(false)

// Стек — «сырые» задачи, у которых НЕТ проекта (0.95). Задача, созданная сразу
// в проекте (страница проекта, подзадача, агент через MCP), уже получила контекст
// от владельца — она не «входящая»: ей не место в стопке разбора. Такие задачи
// никуда не пропадают: они видны на странице своего проекта, детализация для них
// идёт как обычно, а предложение ИИ ждёт на странице самой задачи. Туда же уходит
// задача, которую из стека перенесли в проект пунктом «В другой проект» — этот
// пункт карточка задачи даёт сама, и это ровно тот жест «это к проекту X»
const stack = computed(() =>
  tasks.value.filter((task) => task.detail_state === 'raw' && !task.project),
)

// Пагинация стека: по 30 карточек
const page = ref(1)
const pagedStack = computed(() => stack.value.slice((page.value - 1) * 30, page.value * 30))
watch(() => stack.value.length, (n) => {
  if (page.value > Math.max(1, Math.ceil(n / 30))) page.value = Math.ceil(n / 30)
})

const editing = ref<Task | null>(null)
const drawerOpen = ref(false)

let loaded = false

async function loadAll(silent = false) {
  // silent-опрос (фоновые события) не включает скелетон и не заменяет массивы,
  // если данные не изменились — иначе страница дёргается при каждом refetch'е
  if (!silent || !loaded) loading.value = true
  try {
    const [taskList, projectList] = await Promise.all([api.listTasks(), api.listProjects()])
    if (JSON.stringify(taskList) !== JSON.stringify(tasks.value)) tasks.value = taskList
    if (JSON.stringify(projectList) !== JSON.stringify(projects.value)) projects.value = projectList
    loaded = true
  } catch (e) {
    error.value = String(e)
  } finally {
    loading.value = false
  }
}

// Реактивность (ТЗ 3.14): изменения приходят событиями от сервера — и чужие
// (MCP, другая вкладка), и фоновая LLM-детализация (ai_proposal появится
// через секунды — раньше тут был 5-секундный поллинг)
useRealtimeRefresh(() => loadAll(true), [
  'task.changed',
  'task.deleted',
  'detail.changed',
  'project.changed',
  'project.deleted',
])

onMounted(() => loadAll())

// busy: двойной Enter до очистки поля не должен создавать дубликаты
const quickBusy = ref(false)

async function quickAdd() {
  if (quickBusy.value) return
  const title = quickTitle.value.trim()
  if (!title) return
  quickBusy.value = true
  try {
    await api.createTask(title)
    quickTitle.value = ''
    toast.success({ title: t('stack.added') })
    // наградный тост после основного: провайдер кита держит один тост —
    // награда должна остаться видимой
    notifyCreated()
    await loadAll()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  } finally {
    quickBusy.value = false
  }
}

// Редактирование — в боковой панели (GnDrawer), форма сама сохраняет и утверждает
function startEdit(task: Task) {
  editing.value = task
  drawerOpen.value = true
}

function closeDrawer() {
  drawerOpen.value = false
  editing.value = null
}

async function onSaved() {
  closeDrawer()
  await loadAll()
}

// Новая задача — полная форма (quick-add — просто опция)
const createOpen = ref(false)

// Массовое добавление: форму не закрываем; переход к задаче не делаем —
// drawer умрёт вместе со страницей. Новая «сырая» задача видна в стеке за формой.
function onCreated() {
  // Форма создания на мобильных закрывается после первой задачи (ТЗ 0.86),
  // на десктопе остаётся открытой ради массового ввода
  if (isMobile()) createOpen.value = false
  void loadAll()
}

async function approve(task: Task) {
  try {
    // «Да, всё верно» — принять предложение автодетализации. Пустой ответ модели
    // применять нечего: утверждаем задачу как есть (применение всё равно
    // безопасно — теги дополняют, пустые поля заполняются, но лишний шум ни к чему)
    await api.approveTask(task.id, proposalItems(task).length > 0)
    toast.success({ title: t('common.taskApproved') })
    await loadAll()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

async function redetail(task: Task) {
  try {
    await api.redetailTask(task.id)
    await loadAll()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

// Смена статуса из меню карточки — как в остальных списках задач
async function setStatus(task: Task, status: string) {
  try {
    await api.updateTask(task.id, { status })
    await loadAll()
  } catch (e) {
    toast.error({ title: t('common.error'), text: 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
  try {
    await api.deleteTask(task.id)
    toast.success({ title: t('common.taskDeleted') })
    await loadAll()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

// Пункты меню «⋯», которых у карточки задачи нет: переспросить ИИ можно только
// у сырой задачи, поэтому живёт это здесь, а не в TaskCard (0.95)
function cardMenuExtra(task: Task) {
  return [
    {
      label: t('stack.redetail'),
      icon: 'ph-arrows-counter-clockwise',
      onSelect: () => redetail(task),
    },
  ]
}

// Счётчики шапки: N в стеке · N активных
const activeCount = computed(
  () =>
    tasks.value.filter(
      (task) =>
        task.detail_state === 'approved' && task.status !== 'done' && task.status !== 'cancelled',
    ).length,
)
</script>

<template>
  <section>
    <GnPageHeader kicker="GNexus Tasks" :title="t('home.title')">
      <template #meta>
        <GnBadge variant="info"><i class="ph ph-tray" aria-hidden="true" />{{ t('home.stackCount', { n: stack.length }) }}</GnBadge>
        <GnBadge variant="accent"><i class="ph ph-circle-half" aria-hidden="true" />{{ t('home.activeCount', { n: activeCount }) }}</GnBadge>
      </template>
      <template #actions>
        <GnLoader
          v-if="loading && stack.length"
          circle
          label=""
          :aria-label="t('common.loading')"
          class="refresh-loader"
        />
        <GnButton variant="primary" icon="ph-plus" @click="createOpen = true">
          {{ t('stack.newTask') }}
        </GnButton>
      </template>
    </GnPageHeader>

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

    <!-- Дашборд: активные задачи, просрочка, дедлайны, проекты -->
    <Dashboard :tasks="tasks" :projects="projects" />

    <!-- Стек входящих -->
    <div class="stack-section-title">
      <i class="ph ph-tray" aria-hidden="true" />
      <span>{{ t('nav.stack') }}</span>
      <GnBadge variant="info">{{ stack.length }}</GnBadge>
    </div>

    <!-- Быстрый ввод: многострочное поле (Enter — закинуть, Shift+Enter — перенос) -->
    <form class="quick-add" @submit.prevent="quickAdd">
      <GnTextarea
        v-model="quickTitle"
        :rows="1"
        :placeholder="t('stack.quickPlaceholder')"
        @keydown.enter.exact.prevent="quickAdd"
      />
      <GnButton type="submit" variant="primary" icon="ph-arrow-circle-down">
        {{ t('stack.quickAdd') }}
      </GnButton>
    </form>

    <!-- Скелетон карточек стека: заголовок + строки сниппета, как в реальных -->
    <div v-if="loading && stack.length === 0" class="stack-skeleton" aria-hidden="true">
      <div v-for="i in 3" :key="i" class="card">
        <div class="card-content">
          <span class="skeleton skeleton-title" />
          <span class="skeleton skeleton-line" style="width: 70%" />
          <div class="sk-badges">
            <span v-for="j in 2" :key="j" class="skeleton skeleton-square" />
          </div>
        </div>
      </div>
    </div>

    <GnEmptyState
      v-if="!loading && stack.length === 0"
      icon="ph-tray"
      :title="t('stack.emptyTitle')"
      :text="t('stack.emptyText')"
      class="stack-empty"
    />

    <!-- Карточка стека — обычная карточка задачи (TaskCard, 0.95): тот же
         заголовок-ссылка, сниппет, бейджи, подвал и «⋯»-меню, что в списке задач.
         Своё у стека одно — предложение автодетализации и кнопки утверждения,
         они уходят в слот #extra и прижаты к низу карточки -->
    <div class="task-list">
      <TaskCard
        v-for="task in pagedStack"
        :key="task.id"
        :task="task"
        :menu-extra="cardMenuExtra(task)"
        @status-change="setStatus"
        @delete="askDelete"
        @moved="loadAll"
      >
        <template #extra>
          <div class="stack-extra">
            <!-- Черновое предложение автодетализации. Пустой ответ модели («добавить
                 нечего») молчит и здесь: строка «Предложение ИИ:» без бейджей только
                 путает. Пока ИИ думает (ai_proposal === null) — подсказка об ожидании. -->
            <div v-if="proposalItems(task).length" class="proposal">
              <span class="muted">{{ t('stack.proposal') }}</span>
              <GnBadge
                v-for="(item, i) in proposalItems(task)"
                :key="i"
                variant="neutral"
                :class="item.icon === 'ph-tag' ? 'tag-badge' : undefined"
              >
                <i :class="`ph ${item.icon}`" aria-hidden="true" />{{ item.label }}
              </GnBadge>
            </div>
            <div v-else-if="task.ai_proposal === null" class="proposal">
              <span class="muted">{{ t('stack.proposalPending') }}</span>
            </div>

            <div class="task-actions">
              <GnButton
                variant="success"
                size="sm"
                icon="ph-check"
                :disabled="!task.ai_proposal"
                @click="approve(task)"
              >
                {{ t('stack.approveProposal') }}
              </GnButton>
              <GnButton variant="secondary" size="sm" icon="ph-pencil-simple" @click="startEdit(task)">
                {{ t('stack.detail') }}
              </GnButton>
            </div>
          </div>
        </template>
      </TaskCard>
    </div>

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

    <!-- Редактирование метаданных и утверждение — боковая панель -->
    <GnDrawer v-model:open="drawerOpen" :title="t('stack.detailTitle')" position="right" @close="closeDrawer">
      <TaskForm
        v-if="editing"
        :key="editing.id"
        :task="editing"
        :approve-after-save="true"
        :submit-label="t('stack.approve')"
        @saved="onSaved"
        @cancel="closeDrawer"
      />
    </GnDrawer>

    <GnDrawer v-model:open="createOpen" :title="t('stack.newTask')" position="right">
      <TaskForm @saved="onCreated" @cancel="createOpen = false" />
    </GnDrawer>

    <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>
/* Подпись секции стека: дашборд и стек — два блока одной главной */
.stack-section-title {
  display: flex;
  gap: 0.6rem;
  align-items: center;
  margin: 0 0 0.25rem;
  font-weight: 600;
  font-size: 1.05rem;
}
.stack-section-title > i {
  color: var(--accent, #7aa2f7);
}
/* Важная формочка: вертикальная компоновка, поле на всю ширину контейнера,
   увеличенные вертикальные отступы отделяют её от заголовка и списка задач */
.quick-add {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.75rem;
  margin: 1.75rem 0;
}
.quick-add :deep(.form-group) {
  width: 100%;
  /* у .form-group кита max-width:600px — снимаем: поле на всю ширину контейнера */
  max-width: 100%;
  margin-bottom: 0;
}
.quick-add :deep(.input) {
  height: 3.75rem;
  font-size: 1.1rem;
  line-height: 3.4rem;
}
.stack-skeleton {
  margin-bottom: 1rem;
}
/* карточка-скелет: как реальная карточка стека — тянется на ширину */
.stack-skeleton .card {
  width: 100%;
  max-width: none;
}
.stack-skeleton .card-content > .skeleton-title {
  display: block;
  margin-bottom: 10px;
}
.stack-skeleton .card-content > .skeleton-line {
  display: block;
  margin-bottom: 8px;
}
.sk-badges {
  display: flex;
  gap: 6px;
}
/* Карточки стека — по три в ряд (0.59); скелетон повторяет ту же сетку,
   чтобы загрузка не дёргала раскладку. */
.task-list,
.stack-skeleton {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0.75rem;
}
.task-list > .card,
.stack-skeleton > .card {
  /* сетка ужимает карточку — длинные слова/ссылки в заголовке переносятся,
     а не расширяют трек */
  min-width: 0;
}
/* Карточки ряда одной высоты (0.95; в 0.59 они выравнивались по верху и кнопки
   «Да, всё верно» плясали между соседями). Тянем карточку по высоте ряда, а блок
   предложения прижимаем к низу содержимого — счётчики бейджей у задач разные,
   и без этого кнопки утверждения в ряду стоят на разной высоте */
.task-list :deep(.task-card) {
  height: 100%;
}
.task-list :deep(.card-content) {
  /* колонкой — чтобы margin-top:auto у блока предложения работал */
  display: flex;
  flex-direction: column;
}
.stack-extra {
  margin-top: auto;
}
@media (max-width: 1080px) {
  .task-list,
  .stack-skeleton {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
@media (max-width: 720px) {
  .task-list,
  .stack-skeleton {
    grid-template-columns: minmax(0, 1fr);
  }
}
/* воздух под «Всё спокойно», чтобы пустая страница не слипалась к низу */
.stack-empty {
  margin-bottom: 3rem;
}
.muted {
  color: var(--text-muted, #888);
  font-size: 0.85em;
}
.proposal {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  align-items: center;
  margin-bottom: 0.75rem;
}
.task-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: center;
}
</style>