Newer
Older
gnexus-tasks / frontend / src / views / StackView.vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Task } from '../api'
import { intlLocale } from '../i18n/locale'
import { proposalItems } from '../taskui'
import { useToast } from 'gnexus-ui-kit/vue'
import TaskForm from '../components/TaskForm.vue'

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

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

const stack = ref<Task[]>([])
const quickTitle = ref('')
const error = ref('')
const loading = ref(false)

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

let pollTimer: number | undefined

async function loadStack() {
  loading.value = true
  try {
    stack.value = await api.listTasks({ detail_state: 'raw' })
  } catch (e) {
    error.value = String(e)
  } finally {
    loading.value = false
  }
}

// Опрос: LLM детализирует задачу в фоне — предложение появится через секунды
function startPolling() {
  pollTimer = window.setInterval(async () => {
    if (!editing.value) await loadStack()
  }, 5000)
}

onMounted(async () => {
  await loadStack()
  startPolling()
})
onUnmounted(() => window.clearInterval(pollTimer))

async function quickAdd() {
  const title = quickTitle.value.trim()
  if (!title) return
  try {
    await api.createTask(title)
    quickTitle.value = ''
    toast.success({ title: t('stack.added') })
    await loadStack()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

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

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

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

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

function onCreated() {
  createOpen.value = false
  void loadStack()
}

async function approve(task: Task) {
  try {
    // «Да, всё верно» — принять предложение автодетализации
    await api.approveTask(task.id, task.ai_proposal !== null)
    toast.success({ title: t('common.taskApproved') })
    await loadStack()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

async function redetail(task: Task) {
  try {
    await api.redetailTask(task.id)
    await loadStack()
  } 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 loadStack()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

// Меню «⋯» карточки: переспросить ИИ, удалить
function taskMenu(task: Task) {
  return [
    { label: t('stack.redetail'), icon: 'ph-arrows-counter-clockwise', onSelect: () => redetail(task) },
    { label: t('stack.deleteTask'), icon: 'ph-trash', danger: true, onSelect: () => askDelete(task) },
  ]
}

// Сниппет карточки: описание (предложение ИИ и так показано бейджами ниже)
function snippet(task: Task): string {
  return task.description.trim()
}
</script>

<template>
  <section>
    <GnPageHeader kicker="gntodo" :title="t('stack.title')">
      <template #meta>{{ t('stack.count', { n: stack.length }) }}</template>
      <template #actions>
        <GnButton variant="primary" icon="ph-plus" @click="createOpen = true">
          {{ t('stack.newTask') }}
        </GnButton>
      </template>
    </GnPageHeader>

    <!-- Быстрый ввод: многострочное поле (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>

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

    <GnSkeleton v-if="loading && stack.length === 0" type="block" stack :count="3" class="stack-skeleton" />

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

    <div class="task-list">
      <GnCard v-for="task in stack" :key="task.id" class="task-card">
        <template #title>
          <RouterLink class="card-title" :to="`/tasks/${task.id}`">{{ task.title }}</RouterLink>
        </template>

        <p v-if="snippet(task)" class="snippet">{{ snippet(task) }}</p>

        <div class="task-meta">
          <GnBadge variant="info">{{ t('stack.inStack') }}</GnBadge>
          <span class="muted">{{ new Date(task.created_at).toLocaleString(intlLocale()) }}</span>
        </div>

        <!-- Черновое предложение автодетализации -->
        <div v-if="task.ai_proposal" class="proposal">
          <span class="muted">{{ t('stack.proposal') }}</span>
          <GnBadge v-for="(item, i) in proposalItems(task)" :key="i" variant="neutral">
            <i :class="`ph ${item.icon}`" aria-hidden="true" />{{ item.label }}
          </GnBadge>
        </div>
        <div v-else 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>
          <GnDropdown class="task-menu" :items="taskMenu(task)">
            <template #trigger="{ toggle }">
              <GnIconButton icon="ph-dots-three-outline" :label="t('common.edit')" @click="toggle" />
            </template>
          </GnDropdown>
        </div>
      </GnCard>
    </div>

    <!-- Редактирование метаданных и утверждение — боковая панель -->
    <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>
/* Важная формочка: вертикальная компоновка, поле на всю ширину контейнера,
   увеличенные вертикальные отступы отделяют её от заголовка и списка задач */
.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;
}
.task-list {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}
.card-title {
  color: inherit;
  text-decoration: none;
}
.card-title:hover {
  color: var(--accent, #7aa2f7);
}
.snippet {
  margin: 0 0 0.75rem;
  color: var(--text-muted, #a9b1d6);
  font-size: 0.9em;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.task-meta {
  display: flex;
  gap: 0.75rem;
  align-items: center;
  margin-bottom: 0.75rem;
}
.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;
}
.task-menu {
  margin-left: auto;
}
</style>