Newer
Older
gnexus-tasks / frontend / src / views / TaskView.vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { api, ApiError, type Attachment, type Task } from '../api'
import {
  deadlineLabel,
  formatMinutes,
  formatMoney,
  proposalItems,
  recurrenceLabel,
  renderMarkdown,
  statusOptions,
  statusLabel,
} from '../taskui'
import { useToast } from 'gnexus-ui-kit/vue'
import QuickPrompt from '../components/QuickPrompt.vue'
import TaskBadges from '../components/TaskBadges.vue'
import TaskForm from '../components/TaskForm.vue'
import TaskTree from '../components/TaskTree.vue'

defineOptions({ name: 'TaskView' })

// Страница задачи: просмотр всей детализации (описание md, вложения, параметры,
// предложение ИИ) и редактирование через TaskForm. Подзадачи — TaskTree.

const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const toast = useToast()

const taskId = computed(() => Number(route.params.id))
const task = ref<Task | null>(null)
// утверждённые задачи — для поддерева этой задачи
const tasks = ref<Task[]>([])
const attachments = ref<Attachment[]>([])
const error = ref('')
const loading = ref(false)
const editing = ref(false)
const notFound = ref(false)

const descriptionHtml = computed(() =>
  task.value ? renderMarkdown(task.value.description) : '',
)

async function load() {
  loading.value = true
  notFound.value = false
  error.value = ''
  try {
    task.value = await api.getTask(taskId.value)
    attachments.value = await api.listAttachments(taskId.value)
    tasks.value = await api.listTasks({ detail_state: 'approved' })
  } catch (e) {
    if (e instanceof ApiError && e.status === 404) {
      notFound.value = true
    } else {
      error.value = String(e)
    }
  } finally {
    loading.value = false
  }
}

onMounted(load)

// Параметры — список «термин: значение» (GnDefinitionList)
const fields = computed(() => {
  const task0 = task.value
  if (!task0) return []
  const rows: { key: string; term: string; value: string }[] = []
  if (task0.project)
    rows.push({ key: 'project', term: t('task.field.project'), value: task0.project.name })
  if (task0.priority !== null)
    rows.push({ key: 'priority', term: t('task.field.priority'), value: `P${task0.priority}` })
  if (task0.estimated_minutes)
    rows.push({
      key: 'estimate',
      term: t('task.field.estimate'),
      value: formatMinutes(task0.estimated_minutes),
    })
  if (task0.budget_money !== null || task0.cost_estimate_money !== null)
    rows.push({
      key: 'money',
      term: t('task.field.money'),
      value: [
        task0.budget_money !== null
          ? t('taskui.money.budget', { amount: formatMoney(task0.budget_money) })
          : '',
        task0.cost_estimate_money !== null
          ? t('taskui.money.estimate', { amount: formatMoney(task0.cost_estimate_money) })
          : '',
      ]
        .filter(Boolean)
        .join(' · '),
    })
  const deadline = deadlineLabel(task0)
  if (deadline) rows.push({ key: 'deadline', term: t('task.field.deadline'), value: deadline })
  const recur = recurrenceLabel(task0)
  if (recur) rows.push({ key: 'recurrence', term: t('task.field.recurrence'), value: recur })
  return rows
})

// Смена статуса — дропдаун статусов (отмечен текущий); эмит TaskTree несёт задачу
function statusMenu(task0: Task) {
  return statusOptions().map((o) => ({
    label: o.label,
    icon: o.value === task0.status ? 'ph-check' : undefined,
    onSelect: () => setStatus(task0, o.value),
  }))
}

async function setStatus(_task: Task, status: string) {
  error.value = ''
  try {
    await api.updateTask(_task.id, { status })
    await load()
  } catch (e) {
    error.value = String(e)
  }
}

async function approveProposal() {
  try {
    await api.approveTask(taskId.value, true)
    toast.success({ title: t('common.taskApproved') })
    await load()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

async function redetail() {
  try {
    await api.redetailTask(taskId.value)
    await load()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

function onSaved(fresh: Task) {
  editing.value = false
  task.value = fresh
}

// Подзадачи: дерево строится из утверждённых задач (подзадача попадает сюда
// после утверждения в стеке)
const subtaskOpen = ref(false)

async function createSubtask(title: string) {
  subtaskOpen.value = false
  try {
    await api.createTask(title, '', taskId.value)
    await load()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

// Удаление (страницы и подзадач в дереве) — через подтверждение
const deleteTarget = ref<Task | null>(null)
const confirmOpen = ref(false)

function askDelete(target: Task) {
  deleteTarget.value = target
  confirmOpen.value = true
}

async function remove() {
  const target = deleteTarget.value
  if (!target) return
  confirmOpen.value = false
  try {
    await api.deleteTask(target.id)
    toast.success({ title: t('common.taskDeleted') })
    if (target.id === taskId.value) await router.replace('/list')
    else await load()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}
</script>

<template>
  <section>
    <GnSkeleton v-if="loading && !task" type="block" stack :count="2" class="task-skeleton" />

    <GnEmptyState
      v-if="!loading && notFound"
      icon="ph-question"
      :title="t('task.notFoundTitle')"
      :text="t('task.notFoundText')"
    >
      <template #actions>
        <GnButton variant="primary" icon="ph-list-bullets" @click="router.push('/list')">
          {{ t('nav.list') }}
        </GnButton>
      </template>
    </GnEmptyState>

    <template v-if="task">
      <GnPageHeader kicker="gntodo" :title="task.title">
        <template #meta>
          <TaskBadges :task="task" />
        </template>
      </GnPageHeader>

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

      <!-- Режим правки: полная форма вместо содержимого -->
      <TaskForm
        v-if="editing"
        :key="task.id"
        :task="task"
        :submit-label="t('common.save')"
        @saved="onSaved"
        @cancel="editing = false"
      />

      <template v-else>
        <!-- Действия: статус, правка, ИИ, удаление -->
        <GnToolbar class="task-actions">
          <GnDropdown :label="statusLabel(task.status)" icon="ph-circle-half" :items="statusMenu(task)" />
          <GnButton variant="secondary" icon="ph-pencil-simple" @click="editing = true">
            {{ t('common.edit') }}
          </GnButton>
          <GnButton variant="secondary" icon="ph-arrows-counter-clockwise" @click="redetail">
            {{ t('stack.redetail') }}
          </GnButton>
          <GnButton variant="danger" icon="ph-trash" @click="askDelete(task)">
            {{ t('common.delete') }}
          </GnButton>
        </GnToolbar>

        <!-- Черновое предложение ИИ (сырая задача): принять или переспросить -->
        <GnCard v-if="task.detail_state === 'raw'" class="proposal-card">
          <template #title>{{ t('task.proposalTitle') }}</template>
          <div v-if="task.ai_proposal" class="proposal">
            <GnBadge v-for="(item, i) in proposalItems(task)" :key="i" variant="neutral">
              {{ item }}
            </GnBadge>
          </div>
          <p v-else class="muted">{{ t('stack.proposalPending') }}</p>
          <div class="proposal-actions">
            <GnButton
              variant="accent"
              icon="ph-check"
              :disabled="!task.ai_proposal"
              @click="approveProposal"
            >
              {{ t('stack.approveProposal') }}
            </GnButton>
          </div>
        </GnCard>

        <!-- Описание (Markdown) + вложения -->
        <GnCard class="desc-card">
          <template #title>{{ t('task.description') }}</template>
          <div v-if="task.description.trim()" class="md-view" v-html="descriptionHtml" />
          <p v-else class="muted">{{ t('task.descriptionEmpty') }}</p>
          <div v-if="attachments.length" class="thumbs">
            <a
              v-for="att in attachments"
              :key="att.id"
              :href="api.attachmentUrl(att.id)"
              target="_blank"
              rel="noopener"
            >
              <img :src="api.attachmentUrl(att.id)" :alt="att.original_name" />
            </a>
          </div>
        </GnCard>

        <!-- Параметры -->
        <GnCard v-if="fields.length" class="fields-card">
          <template #title>{{ t('task.fields') }}</template>
          <GnDefinitionList :items="fields" />
        </GnCard>

        <!-- Подзадачи -->
        <div class="subtasks">
          <div class="subtasks-header">
            <h3 class="subtasks-title">{{ t('task.subtasks') }}</h3>
            <GnButton variant="secondary" size="sm" icon="ph-plus" @click="subtaskOpen = true">
              {{ t('tree.addSubtask') }}
            </GnButton>
          </div>
          <p v-if="!tasks.some((c) => c.parent_task_id === (task?.id ?? 0))" class="muted">
            {{ t('task.noSubtasks') }}
          </p>
          <TaskTree
            :tasks="tasks"
            :root-id="task.id"
            @status-change="setStatus"
            @add-subtask="subtaskOpen = true"
            @delete="askDelete"
          />
        </div>
      </template>
    </template>

    <QuickPrompt v-model:open="subtaskOpen" :title="t('tree.promptSubtask')" @confirm="createSubtask" />

    <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>
.task-skeleton {
  margin-bottom: 1rem;
}
.task-actions {
  margin-bottom: 1.5rem;
}
.proposal-card,
.desc-card,
.fields-card {
  margin-bottom: 1.5rem;
}
.proposal {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  align-items: center;
}
.proposal-actions {
  margin-top: 0.75rem;
}
.muted {
  color: var(--text-muted, #888);
  font-size: 0.9em;
}
.md-view {
  overflow-x: auto;
}
.thumbs {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-top: 1rem;
}
.thumbs img {
  max-width: 160px;
  max-height: 120px;
  border-radius: 6px;
  border: 1px solid var(--border, #333);
  display: block;
}
.subtasks-header {
  display: flex;
  gap: 0.75rem;
  align-items: center;
  margin-bottom: 0.75rem;
}
.subtasks-title {
  margin: 0;
  font-size: 1.1rem;
}
</style>