Newer
Older
gnexus-tasks / frontend / src / views / StackView.vue
<script setup lang="ts">
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { api, type Attachment, type Project, type Task } from '../api'
import { formatMinutes } from '../taskui'

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

const stack = ref<Task[]>([])
const projects = ref<Project[]>([])
const tags = ref<{ id: number; name: string }[]>([])
const quickTitle = ref('')
const error = ref('')
const loading = ref(false)

const editing = ref<Task | null>(null)
const editForm = ref({
  title: '',
  description: '',
  project_id: '' as string | number,
  tagSelection: {} as Record<number, boolean>,
  priority: null as number | null,
  estimated_minutes: null as number | null,
  budget_money: null as number | null,
  cost_estimate_money: null as number | null,
})
const showPreview = ref(false)
const attachments = ref<Attachment[]>([])
const uploading = ref(false)

const markdownPreview = computed(() =>
  DOMPurify.sanitize(marked.parse(editForm.value.description, { async: 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
  }
}

async function loadCatalog() {
  try {
    projects.value = await api.listProjects()
    tags.value = await api.listTags()
  } catch (e) {
    error.value = String(e)
  }
}

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

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

async function quickAdd() {
  const title = quickTitle.value.trim()
  if (!title) return
  error.value = ''
  try {
    await api.createTask(title)
    quickTitle.value = ''
    await loadStack()
  } catch (e) {
    error.value = String(e)
  }
}

function selectedTagIds(): number[] {
  return Object.entries(editForm.value.tagSelection)
    .filter(([, on]) => on)
    .map(([id]) => Number(id))
}

function startEdit(task: Task) {
  editing.value = task
  const selection: Record<number, boolean> = {}
  task.tags.forEach((t) => (selection[t.id] = true))
  editForm.value = {
    title: task.title,
    description: task.description,
    project_id: task.project?.id ?? '',
    tagSelection: selection,
    priority: task.priority,
    estimated_minutes: task.estimated_minutes,
    budget_money: task.budget_money,
    cost_estimate_money: task.cost_estimate_money,
  }
  showPreview.value = false
  void loadAttachments(task.id)
}

async function loadAttachments(taskId: number) {
  try {
    attachments.value = await api.listAttachments(taskId)
  } catch (e) {
    error.value = String(e)
  }
}

async function saveEdit() {
  if (!editing.value) return
  error.value = ''
  try {
    await api.updateTask(editing.value.id, {
      title: editForm.value.title,
      description: editForm.value.description,
      project_id: editForm.value.project_id === '' ? null : Number(editForm.value.project_id),
      tag_ids: selectedTagIds(),
      priority: editForm.value.priority,
      estimated_minutes: editForm.value.estimated_minutes,
      budget_money: editForm.value.budget_money,
      cost_estimate_money: editForm.value.cost_estimate_money,
    })
    await api.approveTask(editing.value.id)
    editing.value = null
    attachments.value = []
    await loadStack()
  } catch (e) {
    error.value = String(e)
  }
}

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

async function redetail(task: Task) {
  error.value = ''
  try {
    await api.redetailTask(task.id)
    await loadStack()
  } catch (e) {
    error.value = String(e)
  }
}

async function remove(task: Task) {
  error.value = ''
  try {
    await api.deleteTask(task.id)
    await loadStack()
  } catch (e) {
    error.value = String(e)
  }
}

// Вставка изображений из буфера обмена прямо в редактор описания
async function onPaste(event: ClipboardEvent) {
  const files = Array.from(event.clipboardData?.files ?? []).filter((f) =>
    f.type.startsWith('image/'),
  )
  if (!files.length || !editing.value) return
  event.preventDefault()
  uploading.value = true
  try {
    const saved = await api.uploadAttachments(editing.value.id, files)
    attachments.value.push(...saved)
  } catch (e) {
    error.value = String(e)
  } finally {
    uploading.value = false
  }
}

async function removeAttachment(att: Attachment) {
  error.value = ''
  try {
    await api.deleteAttachment(att.id)
    attachments.value = attachments.value.filter((a) => a.id !== att.id)
  } catch (e) {
    error.value = String(e)
  }
}

async function createProjectInline() {
  const name = window.prompt('Название нового проекта:')
  if (!name) return
  try {
    const p = await api.createProject(name.trim())
    projects.value.push(p)
    editForm.value.project_id = p.id
  } catch (e) {
    error.value = String(e)
  }
}

async function createTagInline() {
  const name = window.prompt('Название нового тега:')
  if (!name) return
  try {
    const t = await api.createTag(name.trim())
    tags.value.push(t)
    editForm.value.tagSelection[t.id] = true
  } catch (e) {
    error.value = String(e)
  }
}

const projectOptions = () => [
  { value: '', label: '— без проекта —' },
  ...projects.value.map((p) => ({ value: String(p.id), label: p.name })),
]

// Подписи чернового предложения LLM
function proposalItems(task: Task): string[] {
  const p = task.ai_proposal
  if (!p) return []
  const items: string[] = []
  p.tags.forEach((t) => items.push(t))
  if (p.project) items.push(p.new_project ? `+ проект «${p.project}»` : p.project)
  if (p.priority !== null) items.push(`приоритет ${p.priority}`)
  if (p.estimated_minutes !== null && p.estimated_minutes !== undefined)
    items.push(`≈ ${formatMinutes(p.estimated_minutes)}`)
  return items
}
</script>

<template>
  <section>
    <GnPageHeader kicker="gntodo" title="Стек входящих">
      <template #meta>Задач в стеке: {{ stack.length }}</template>
    </GnPageHeader>

    <!-- Быстрый ввод: одна строка, без метаданных -->
    <form class="quick-add" @submit.prevent="quickAdd">
      <GnInput
        v-model="quickTitle"
        icon="ph-plus"
        placeholder="Закинуть задачу — только текст, остальное потом…"
        autofocus
      />
      <GnButton type="submit" variant="primary" icon="ph-arrow-circle-down">Закинуть</GnButton>
    </form>

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

    <GnEmptyState
      v-if="!loading && stack.length === 0"
      icon="ph-inbox"
      title="Стек пуст"
      text="Закиньте первую задачу — детализация подберётся автоматически."
    />

    <div class="task-list">
      <GnCard v-for="task in stack" :key="task.id" class="task-card">
        <template #title>
          <template v-if="editing?.id !== task.id">{{ task.title }}</template>
        </template>

        <!-- Редактирование: метаданные правятся вручную, затем утверждение -->
        <form v-if="editing?.id === task.id" class="edit-form" @submit.prevent="saveEdit">
          <GnInput v-model="editForm.title" label="Заголовок" required />
          <GnTextarea
            v-model="editForm.description"
            label="Описание (Markdown, Ctrl+V — картинка)"
            :rows="4"
            @paste="onPaste"
          />
          <GnCheckbox v-model="showPreview" label="Предпросмотр Markdown" />
          <div v-if="showPreview" class="md-preview" v-html="markdownPreview" />

          <GnSelect
            v-model="editForm.project_id"
            label="Проект"
            icon="ph-folder"
            :options="projectOptions()"
          />
          <div class="form-row-actions">
            <GnButton
              type="button"
              variant="secondary"
              size="sm"
              icon="ph-plus"
              @click="createProjectInline"
            >
              Новый проект
            </GnButton>
          </div>
          <fieldset class="tags-fieldset">
            <legend>Теги</legend>
            <GnCheckbox
              v-for="t in tags"
              :key="t.id"
              v-model="editForm.tagSelection[t.id]"
              :label="t.name"
            />
            <GnButton
              type="button"
              variant="secondary"
              size="sm"
              icon="ph-plus"
              @click="createTagInline"
            >
              Новый тег
            </GnButton>
          </fieldset>
          <GnInput v-model.number="editForm.priority" label="Приоритет (0–10)" type="number" />
          <GnInput
            v-model.number="editForm.estimated_minutes"
            label="Оценка времени, минут"
            type="number"
            min="1"
          />
          <div class="budget-row">
            <GnInput
              v-model.number="editForm.budget_money"
              label="Бюджет (в валюте настроек)"
              type="number"
              min="0"
            />
            <GnInput
              v-model.number="editForm.cost_estimate_money"
              label="Оценка затрат (в валюте настроек)"
              type="number"
              min="0"
            />
          </div>

          <!-- Вложения: вставленные Ctrl+V изображения -->
          <div v-if="attachments.length || uploading" class="attachments">
            <GnBadge v-if="uploading" variant="info">загрузка…</GnBadge>
            <div class="thumbs">
              <div v-for="att in attachments" :key="att.id" class="thumb">
                <img :src="api.attachmentUrl(att.id)" :alt="att.original_name" />
                <GnIconButton
                  icon="ph-x"
                  label="Удалить вложение"
                  size="sm"
                  @click="removeAttachment(att)"
                />
              </div>
            </div>
          </div>

          <div class="form-actions">
            <GnButton type="submit" variant="accent" icon="ph-check-circle">Утвердить</GnButton>
            <GnButton type="button" variant="secondary" @click="editing = null">Отмена</GnButton>
          </div>
        </form>

        <!-- Просмотр сырой задачи -->
        <template v-else>
          <div class="task-meta">
            <GnBadge variant="info">в стеке</GnBadge>
            <span class="muted">{{ new Date(task.created_at).toLocaleString('ru') }}</span>
          </div>

          <!-- Черновое предложение автодетализации -->
          <div v-if="task.ai_proposal" class="proposal">
            <span class="muted">Предложение ИИ:</span>
            <GnBadge v-for="(item, i) in proposalItems(task)" :key="i" variant="neutral">
              {{ item }}
            </GnBadge>
          </div>
          <div v-else class="proposal">
            <span class="muted">Детализация подбирается…</span>
          </div>

          <div class="task-actions">
            <GnButton
              variant="accent"
              size="sm"
              icon="ph-check"
              :disabled="!task.ai_proposal"
              @click="approve(task)"
            >
              Да, всё верно
            </GnButton>
            <GnButton variant="secondary" size="sm" icon="ph-pencil-simple" @click="startEdit(task)">
              Детализировать
            </GnButton>
            <GnIconButton
              icon="ph-arrows-counter-clockwise"
              label="Переспросить ИИ"
              @click="redetail(task)"
            />
            <GnIconButton icon="ph-trash" label="Удалить задачу" @click="remove(task)" />
          </div>
        </template>
      </GnCard>
    </div>
  </section>
</template>

<style scoped>
.quick-add {
  display: flex;
  gap: 0.75rem;
  align-items: start;
  margin-bottom: 1.5rem;
}
.quick-add > :first-child {
  flex: 1;
}
.task-list {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}
.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;
  gap: 0.5rem;
  align-items: center;
}
.edit-form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}
.form-row-actions {
  margin-top: -0.5rem;
}
.tags-fieldset {
  border: 1px solid var(--border, #333);
  border-radius: 6px;
  padding: 0.75rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  align-items: center;
}
.tags-fieldset legend {
  padding: 0 0.5rem;
  font-size: 0.85em;
  color: var(--text-muted, #888);
}
.md-preview {
  border: 1px solid var(--border, #333);
  border-radius: 6px;
  padding: 0.75rem;
  overflow-x: auto;
}
.attachments {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.thumbs {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
}
.thumb {
  position: relative;
}
.thumb img {
  max-width: 120px;
  max-height: 90px;
  border-radius: 6px;
  border: 1px solid var(--border, #333);
  display: block;
}
.thumb .gn-icon-button {
  position: absolute;
  top: 4px;
  right: 4px;
}
.form-actions {
  display: flex;
  gap: 0.75rem;
}
.budget-row {
  display: flex;
  gap: 0.75rem;
}
.budget-row > * {
  flex: 1;
}
</style>