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 { useI18n } from 'vue-i18n'
import { api, type Attachment, type Project, type Task } from '../api'
import { intlLocale } from '../i18n/locale'
import { deadlinePeriodOptions, formatMinutes, recurKindOptions, weekdayShort } from '../taskui'
import { useToast } from 'gnexus-ui-kit/vue'
import QuickPrompt from '../components/QuickPrompt.vue'

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

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

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 drawerOpen = ref(false)
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,
  deadline_date: '' as string,
  deadline_period: '' as string,
  task_type: 'one_time',
  recur_kind: '' as string,
  recur_interval_days: 1 as number | null,
  recurWeekday: {} as Record<string, boolean>,
  recur_day_of_month: 1 as number | null,
})

// Дни недели для правила повторения (ISO: пн=1..вс=7) — имена через Intl
const WEEKDAYS = computed(() =>
  [1, 2, 3, 4, 5, 6, 7].map((n) => ({ value: String(n), label: weekdayShort(n) })),
)
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
  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) })
  }
}

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

// Редактирование — в боковой панели (GnDrawer), карточка остаётся компактной
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,
    deadline_date: task.deadline_date ?? '',
    deadline_period: task.deadline_period ?? '',
    task_type: task.task_type ?? 'one_time',
    recur_kind: task.recur_kind ?? '',
    recur_interval_days: task.recur_interval_days ?? 1,
    recurWeekday: task.recur_weekdays
      ? Object.fromEntries(task.recur_weekdays.split(',').map((d) => [d, true]))
      : {},
    recur_day_of_month: task.recur_day_of_month ?? 1,
  }
  showPreview.value = false
  attachments.value = []
  void loadAttachments(task.id)
  drawerOpen.value = true
}

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

async function loadAttachments(taskId: number) {
  try {
    attachments.value = await api.listAttachments(taskId)
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

async function saveEdit() {
  if (!editing.value) return
  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,
      deadline_date: editForm.value.deadline_date === '' ? null : editForm.value.deadline_date,
      deadline_period: editForm.value.deadline_period === '' ? null : editForm.value.deadline_period,
      task_type: editForm.value.task_type,
      recur_kind: editForm.value.task_type === 'recurring' && editForm.value.recur_kind
        ? editForm.value.recur_kind
        : null,
      recur_interval_days:
        editForm.value.task_type === 'recurring' && editForm.value.recur_kind === 'interval'
          ? editForm.value.recur_interval_days
          : null,
      recur_weekdays:
        editForm.value.task_type === 'recurring' && editForm.value.recur_kind === 'weekdays'
          ? Object.keys(editForm.value.recurWeekday)
              .filter((d) => editForm.value.recurWeekday[d])
              .sort()
              .join(',') || null
          : null,
      recur_day_of_month:
        editForm.value.task_type === 'recurring' && editForm.value.recur_kind === 'monthly'
          ? editForm.value.recur_day_of_month
          : null,
    })
    await api.approveTask(editing.value.id)
    toast.success({ title: t('common.taskApproved') })
    closeDrawer()
    await loadStack()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

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) },
  ]
}

// Вставка изображений из буфера обмена прямо в редактор описания
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) {
    toast.error({ title: t('common.error'), text: String(e) })
  } finally {
    uploading.value = false
  }
}

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

// Создание проекта/тега на лету — модальное окно вместо window.prompt
const promptOpen = ref(false)
const promptTitle = ref('')
let promptKind: 'project' | 'tag' = 'project'

function openPrompt(kind: 'project' | 'tag') {
  promptKind = kind
  promptTitle.value = kind === 'project' ? t('stack.promptProject') : t('stack.promptTag')
  promptOpen.value = true
}

async function onPromptConfirm(name: string) {
  try {
    if (promptKind === 'project') {
      const p = await api.createProject(name)
      projects.value.push(p)
      editForm.value.project_id = p.id
    } else {
      const tag = await api.createTag(name)
      tags.value.push(tag)
      editForm.value.tagSelection[tag.id] = true
    }
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

const projectOptions = () => [
  { value: '', label: t('common.noProject') },
  ...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((tag) => items.push(tag))
  if (p.project)
    items.push(p.new_project ? t('stack.proposalNewProject', { name: p.project }) : p.project)
  if (p.priority !== null) items.push(t('stack.proposalPriority', { n: 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="t('stack.title')">
      <template #meta>{{ t('stack.count', { n: stack.length }) }}</template>
    </GnPageHeader>

    <!-- Быстрый ввод: одна строка, без метаданных -->
    <form class="quick-add" @submit.prevent="quickAdd">
      <GnInput
        v-model="quickTitle"
        icon="ph-plus"
        :placeholder="t('stack.quickPlaceholder')"
        autofocus
      />
      <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-inbox"
      :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>{{ task.title }}</template>

        <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">
            {{ item }}
          </GnBadge>
        </div>
        <div v-else class="proposal">
          <span class="muted">{{ t('stack.proposalPending') }}</span>
        </div>

        <div class="task-actions">
          <GnButton
            variant="accent"
            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">
      <form v-if="editing" class="edit-form" @submit.prevent="saveEdit">
        <GnInput v-model="editForm.title" :label="t('stack.form.title')" required />
        <GnTextarea
          v-model="editForm.description"
          :label="t('stack.form.description')"
          :rows="4"
          @paste="onPaste"
        />
        <GnCheckbox v-model="showPreview" :label="t('stack.form.preview')" />
        <div v-if="showPreview" class="md-preview" v-html="markdownPreview" />

        <GnSelect
          v-model="editForm.project_id"
          :label="t('stack.form.project')"
          icon="ph-folder"
          :options="projectOptions()"
        />
        <div class="form-row-actions">
          <GnButton
            type="button"
            variant="secondary"
            size="sm"
            icon="ph-plus"
            @click="openPrompt('project')"
          >
            {{ t('stack.newProject') }}
          </GnButton>
        </div>
        <fieldset class="tags-fieldset">
          <legend>{{ t('stack.form.tags') }}</legend>
          <GnCheckbox
            v-for="tag in tags"
            :key="tag.id"
            v-model="editForm.tagSelection[tag.id]"
            :label="tag.name"
          />
          <GnButton type="button" variant="secondary" size="sm" icon="ph-plus" @click="openPrompt('tag')">
            {{ t('stack.newTag') }}
          </GnButton>
        </fieldset>
        <GnInput v-model.number="editForm.priority" :label="t('stack.form.priority')" type="number" />
        <GnInput
          v-model.number="editForm.estimated_minutes"
          :label="t('stack.form.estimate')"
          type="number"
          min="1"
        />
        <div class="budget-row">
          <GnInput
            v-model.number="editForm.budget_money"
            :label="t('stack.form.budget')"
            type="number"
            min="0"
          />
          <GnInput
            v-model.number="editForm.cost_estimate_money"
            :label="t('stack.form.cost')"
            type="number"
            min="0"
          />
        </div>
        <fieldset class="tags-fieldset">
          <legend>{{ t('stack.deadline.legend') }}</legend>
          <GnInput v-model="editForm.deadline_date" :label="t('stack.deadline.date')" type="date" />
          <GnSelect
            v-model="editForm.deadline_period"
            :label="t('stack.deadline.period')"
            :options="[{ value: '', label: t('common.none') }, ...deadlinePeriodOptions()]"
          />
        </fieldset>
        <fieldset class="tags-fieldset">
          <legend>{{ t('stack.recur.legend') }}</legend>
          <GnSelect
            v-model="editForm.task_type"
            :options="[
              { value: 'one_time', label: t('stack.recur.oneTime') },
              { value: 'recurring', label: t('stack.recur.recurring') },
            ]"
          />
          <template v-if="editForm.task_type === 'recurring'">
            <GnSelect
              v-model="editForm.recur_kind"
              :label="t('stack.recur.kind')"
              :options="[{ value: '', label: t('common.choose') }, ...recurKindOptions()]"
            />
            <GnInput
              v-if="editForm.recur_kind === 'interval'"
              v-model.number="editForm.recur_interval_days"
              :label="t('stack.recur.interval')"
              type="number"
              min="1"
              max="365"
            />
            <template v-if="editForm.recur_kind === 'weekdays'">
              <fieldset class="tags-fieldset weekdays-fieldset">
                <legend>{{ t('stack.recur.weekdays') }}</legend>
                <GnCheckbox
                  v-for="d in WEEKDAYS"
                  :key="d.value"
                  v-model="editForm.recurWeekday[d.value]"
                  :label="d.label"
                />
              </fieldset>
            </template>
            <GnInput
              v-if="editForm.recur_kind === 'monthly'"
              v-model.number="editForm.recur_day_of_month"
              :label="t('stack.recur.monthlyDay')"
              type="number"
              min="1"
              max="31"
            />
          </template>
        </fieldset>

        <!-- Вложения: вставленные Ctrl+V изображения -->
        <div v-if="attachments.length || uploading" class="attachments">
          <GnBadge v-if="uploading" variant="info">{{ t('stack.uploading') }}</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="t('stack.deleteAttachment')"
                size="sm"
                @click="removeAttachment(att)"
              />
            </div>
          </div>
        </div>

        <div class="form-actions">
          <GnButton type="submit" variant="accent" icon="ph-check-circle">
            {{ t('stack.approve') }}
          </GnButton>
          <GnButton type="button" variant="secondary" @click="closeDrawer">
            {{ t('common.cancel') }}
          </GnButton>
        </div>
      </form>
    </GnDrawer>

    <QuickPrompt v-model:open="promptOpen" :title="promptTitle" @confirm="onPromptConfirm" />

    <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-wrap: wrap;
  gap: 0.75rem;
  align-items: start;
  margin-bottom: 1.5rem;
}
.quick-add > :first-child {
  flex: 1 1 14rem;
}
.stack-skeleton {
  margin-bottom: 1rem;
}
.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;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: center;
}
.task-menu {
  margin-left: auto;
}
.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;
  flex-wrap: wrap;
  gap: 0.75rem;
}
.budget-row {
  display: flex;
  gap: 0.75rem;
}
.budget-row > * {
  flex: 1;
}
</style>