Newer
Older
gnexus-tasks / frontend / src / views / TaskView.vue
<script setup lang="ts">
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { api, ApiError, type Attachment, type Tag, type Task, type TaskUpdateInput } from '../api'
import {
  deadlineLabel,
  deadlineOverdue,
  formatDate,
  formatMinutes,
  formatMoney,
  priorityLabel,
  priorityToGrade,
  gradeToPriority,
  priorityVariant,
  priorityOptions,
  deadlinePeriodOptions,
  recurrenceLabel,
  renderMarkdown,
  setPageTitle,
  statusOptions,
  statusLabel,
  statusVariant,
} from '../taskui'
import { useToast } from 'gnexus-ui-kit/vue'
import { celebrateEarned } from '../gamification'
import InlineField from '../components/InlineField.vue'
import MdEditor from '../components/MdEditor.vue'
import MdLightbox from '../components/MdLightbox.vue'
import TaskBadges from '../components/TaskBadges.vue'
import TaskForm from '../components/TaskForm.vue'
import TaskCard from '../components/TaskCard.vue'

defineOptions({ name: 'TaskView' })

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

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) : '',
)

// Тайтл вкладки — заголовок задачи (после загрузки уточняет meta маршрута)
watchEffect(() => {
  setPageTitle(task.value?.title ?? t('nav.task'))
})

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()
    tagNames.value = task.value.tags.map((t0) => t0.name)
    if (!tagsCatalog.value.length) tagsCatalog.value = await api.listTags()
  } catch (e) {
    if (e instanceof ApiError && e.status === 404) {
      notFound.value = true
    } else {
      error.value = String(e)
    }
  } finally {
    loading.value = false
  }
}

onMounted(load)

// Переход «подзадача → родитель» (и любой /tasks/:id → /tasks/:id) переиспользует
// этот же инстанс компонента — onMounted не сработает; перезагружаемся по смене id
watch(taskId, () => {
  editing.value = false
  void load()
})

// ---------- Инлайн-редактирование полей ----------
// Каждый InlineField правит своё поле: draft сеется на @open, ✓ шлёт PATCH
// одного поля (или группы), ответ заменяет task.value — бейджи и шапка
// обновляются сами.

const draftTitle = ref('')
const draftDescription = ref('')
const descEditing = ref(false)
const draftPriority = ref('')
const draftEstimate = ref<number | null>(null)
const draftActual = ref<number | null>(null)
const draftBudget = ref<number | null>(null)
const draftCost = ref<number | null>(null)
const draftDeadlineDate = ref('')
const draftDeadlinePeriod = ref('')

const priorityOptionsWithNone = computed(() => [
  { value: '', label: t('common.none') },
  ...priorityOptions(),
])
const deadlinePeriodsWithNone = computed(() => [
  { value: '', label: t('common.none') },
  ...deadlinePeriodOptions(),
])

// Инлайн-PATCH: ошибка → тост + throw (InlineField оставит редактор открытым)
async function patchTask(patch: TaskUpdateInput) {
  const task0 = task.value
  if (!task0) return
  try {
    task.value = await api.updateTask(task0.id, patch)
    tagNames.value = task.value.tags.map((t0) => t0.name)
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
    throw e
  }
}

async function saveTitle() {
  const title = draftTitle.value.trim()
  if (!title) throw new Error('empty') // пустой заголовок не сохраняем
  await patchTask({ title })
}

const descBusy = ref(false)

async function saveDescription() {
  descBusy.value = true
  try {
    await patchTask({ description: draftDescription.value })
    descEditing.value = false
  } catch {
    // тост уже показан patchTask
  } finally {
    descBusy.value = false
  }
}

// Картинки в описании: загрузка из буфера (MdEditor вставит markdown в курсор)
async function uploadImages(files: File[]) {
  const task0 = task.value
  if (!task0) return []
  try {
    const saved = await api.uploadAttachments(task0.id, files)
    attachments.value.push(...saved)
    return saved
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
    return []
  }
}

// Клик по картинке в отображаемом описании → просмотр на весь экран
const descImage = ref<{ url: string; name: string } | null>(null)

function onDescClick(e: MouseEvent) {
  const target = e.target as HTMLElement
  if (target.tagName === 'IMG') {
    const img = target as HTMLImageElement
    descImage.value = { url: img.getAttribute('src') ?? '', name: img.alt }
  }
}

async function savePriority() {
  await patchTask({
    priority: draftPriority.value === '' ? null : gradeToPriority(draftPriority.value),
  })
}

// Пустая строка number-input — убираем значение (null), 0 — валидное число
function numOrNull(v: number | null): number | null {
  return v === null || Number.isNaN(v) ? null : v
}

async function saveEstimate() {
  await patchTask({ estimated_minutes: numOrNull(draftEstimate.value) })
}

async function saveActual() {
  await patchTask({ actual_minutes: numOrNull(draftActual.value) })
}

async function saveMoney() {
  await patchTask({
    budget_money: numOrNull(draftBudget.value),
    cost_estimate_money: numOrNull(draftCost.value),
  })
}

// Дедлайн: дата и период взаимоисключающие (как в ТЗ) — оба заполнены нельзя
async function saveDeadline() {
  if (draftDeadlineDate.value && draftDeadlinePeriod.value) {
    toast.error({ title: t('task.deadlineConflict') })
    throw new Error('conflict')
  }
  await patchTask({
    deadline_date: draftDeadlineDate.value || null,
    deadline_period: draftDeadlinePeriod.value || null,
  })
}

// Сайдбар «Параметры» — строка = иконка + термин + значение
// Инлайн-правка параметров: строки показываются всегда (пустые — «не указано»
// и кликабельны); taskType read-only (связан с recur_* — правится в полной форме)
const paramRows = computed(() => {
  const task0 = task.value
  if (!task0) return []
  const rows: {
    key: string
    icon: string
    term: string
    value: string
    warn?: boolean
    badge?: string
    empty?: boolean
    inline?: boolean
  }[] = []
  rows.push({
    key: 'taskType',
    icon: task0.task_type === 'recurring' ? 'ph-repeat' : 'ph-arrow-bend-down-right',
    term: t('task.field.taskType'),
    value:
      task0.task_type === 'recurring'
        ? recurrenceLabel(task0) || t('stack.recur.recurring')
        : t('stack.recur.oneTime'),
  })
  rows.push({
    key: 'priority',
    icon: 'ph-flag',
    term: t('task.field.priority'),
    value: task0.priority === null ? '' : priorityLabel(task0.priority),
    badge: task0.priority === null ? undefined : priorityVariant(task0.priority),
    empty: task0.priority === null,
    inline: true,
  })
  rows.push({
    key: 'estimate',
    icon: 'ph-clock',
    term: t('task.field.estimate'),
    value: task0.estimated_minutes ? `≈ ${formatMinutes(task0.estimated_minutes)}` : '',
    empty: !task0.estimated_minutes,
    inline: true,
  })
  rows.push({
    key: 'actual',
    icon: 'ph-timer',
    term: t('task.field.actual'),
    value: task0.actual_minutes ? formatMinutes(task0.actual_minutes) : '',
    empty: !task0.actual_minutes,
    inline: true,
  })
  rows.push({
    key: 'money',
    icon: 'ph-coins',
    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(' · '),
    empty: task0.budget_money === null && task0.cost_estimate_money === null,
    inline: true,
  })
  const deadline = deadlineLabel(task0)
  rows.push({
    key: 'deadline',
    icon: 'ph-alarm',
    term: t('task.field.deadline'),
    value: deadline,
    warn: deadlineOverdue(task0) && task0.status !== 'done' && task0.status !== 'cancelled',
    empty: !deadline,
    inline: true,
  })
  return rows
})

// Сайдбар «Даты» — что с задачей происходило и когда
const dateRows = computed(() => {
  const task0 = task.value
  if (!task0) return []
  const rows: { key: string; icon: string; term: string; value: string }[] = [
    { key: 'created', icon: 'ph-calendar-plus', term: t('task.field.created'), value: formatDate(task0.created_at) },
  ]
  if (task0.approved_at)
    rows.push({ key: 'approved', icon: 'ph-circle-wavy-check', term: t('task.field.approved'), value: formatDate(task0.approved_at) })
  if (task0.done_at)
    rows.push({ key: 'done', icon: 'ph-check-circle', term: t('task.field.done'), value: formatDate(task0.done_at) })
  return rows
})

// Предложение ИИ — построчно с иконками (не сжатые бейджи)
const proposalRows = computed(() => {
  const p = task.value?.ai_proposal
  if (!p) return []
  const rows: { icon: string; term: string; value: string }[] = []
  if (p.tags.length)
    rows.push({ icon: 'ph-tag', term: t('stack.form.tags'), value: p.tags.join(', ') })
  if (p.project)
    rows.push({
      icon: p.new_project ? 'ph-folder-plus' : 'ph-folder',
      term: t('task.field.project'),
      value: p.new_project ? t('stack.proposalNewProject', { name: p.project }) : p.project,
    })
  if (p.priority !== null)
    rows.push({
      icon: 'ph-flag',
      term: t('task.field.priority'),
      value: priorityLabel(p.priority),
    })
  if (p.estimated_minutes !== null && p.estimated_minutes !== undefined)
    rows.push({
      icon: 'ph-clock',
      term: t('task.field.estimate'),
      value: `≈ ${formatMinutes(p.estimated_minutes)}`,
    })
  return rows
})

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

// Действия страницы — меню в шапке (статус вынесен в сайдбар)
function pageActions(): { label: string; icon: string; danger?: boolean; onSelect: () => void }[] {
  const task0 = task.value
  if (!task0) return []
  return [
    { label: t('common.edit'), icon: 'ph-pencil-simple', onSelect: () => (editing.value = true) },
    { label: t('stack.redetail'), icon: 'ph-arrows-counter-clockwise', onSelect: redetail },
    { label: t('common.delete'), icon: 'ph-trash', danger: true, onSelect: () => askDelete(task0) },
  ]
}

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

// Переключатель «не хочется делать»: метку можно ставить и снимать до закрытия
async function toggleMentallyHard() {
  const task0 = task.value
  if (!task0) return
  try {
    task.value = await api.updateTask(task0.id, { mentally_hard: !task0.mentally_hard })
  } catch (e) {
    toast.error({ title: t('common.error'), text: 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) })
  }
}

// «Я сам»: утвердить без предложения ИИ (apply_proposal=false) — детали
// пользователь заполняет сам, форма правки не открывается принудительно
async function doItMyself() {
  try {
    task.value = await api.approveTask(taskId.value, false)
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

function onSaved(fresh: Task) {
  editing.value = false
  task.value = fresh
  tagNames.value = fresh.tags.map((t0) => t0.name)
}

// Подзадачи: прямые дети текущей задачи, карточками
// Родительская задача (для чипа-перехода в шапке) и прямые дети — карточками
const parentTask = computed(() => tasks.value.find((x) => x.id === task.value?.parent_task_id) ?? null)
const childTasks = computed(() => tasks.value.filter((c) => c.parent_task_id === taskId.value))
const subtaskOpen = ref(false)

function onSubtaskSaved() {
  subtaskOpen.value = false
  void load()
}

// Теги в сайдбаре: быстрое добавление/удаление прямо на странице.
// Новое имя создаёт тег в каталоге; каждое изменение — PATCH tag_ids.
const tagNames = ref<string[]>([])
const tagsCatalog = ref<Tag[]>([])

async function onTagAdd(name: string) {
  const task0 = task.value
  if (!task0 || task0.tags.some((t0) => t0.name === name)) return
  try {
    let tag = tagsCatalog.value.find((t0) => t0.name === name)
    if (!tag) {
      tag = await api.createTag(name)
      tagsCatalog.value.push(tag)
    }
    task.value = await api.updateTask(task0.id, {
      tag_ids: [...task0.tags.map((t0) => t0.id), tag.id],
    })
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

async function onTagRemove(name: string) {
  const task0 = task.value
  if (!task0) return
  try {
    task.value = await api.updateTask(task0.id, {
      tag_ids: task0.tags.filter((t0) => t0.name !== name).map((t0) => t0.id),
    })
  } 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="GNexus Tasks" :title="task.title">
        <template #title>
          <!-- Инлайн-правка заголовка: клик по нему же → поле на его месте -->
          <InlineField :label="t('task.editTitle')" :save="saveTitle" @open="draftTitle = task.title">
            <template #display="{ edit }">
              <button class="title-editable" type="button" @click="edit">{{ task.title }}</button>
            </template>
            <template #edit="{ save }">
              <GnInput
                v-model="draftTitle"
                autofocus
                class="title-input"
                @keydown.enter="save"
              />
            </template>
          </InlineField>
        </template>
        <template #meta>
          <RouterLink v-if="parentTask" :to="`/tasks/${parentTask.id}`" class="meta-link">
            <GnBadge variant="neutral">
              <i class="ph ph-arrow-bend-up-left" aria-hidden="true" />{{ parentTask.title }}
            </GnBadge>
          </RouterLink>
          <RouterLink v-if="task.project" :to="`/projects/${task.project.id}`" class="meta-link">
            <GnBadge variant="neutral">
              <i class="ph ph-folder" aria-hidden="true" />{{ task.project.name }}
            </GnBadge>
          </RouterLink>
          <TaskBadges :task="task" :hide-project="true" />
        </template>
        <template #actions>
          <GnDropdown :items="pageActions()">
            <template #trigger="{ toggle }">
              <GnIconButton icon="ph-dots-three" :label="t('task.actions')" :title="t('task.actions')" @click="toggle" />
            </template>
          </GnDropdown>
        </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>
        <div class="task-grid">
          <div class="task-main">
            <!-- Черновое предложение ИИ (сырая задача): построчно, принять или переспросить -->
            <GnCard v-if="task.detail_state === 'raw'" class="proposal-card">
              <template #title>
                <span class="card-title-icon"><i class="ph ph-sparkle" /></span>
                {{ t('task.proposalTitle') }}
              </template>
              <p class="muted proposal-hint">{{ t('task.proposalHint') }}</p>
              <div v-if="task.ai_proposal" class="proposal-rows">
                <div v-for="row in proposalRows" :key="row.term" class="p-row">
                  <i :class="`ph ${row.icon}`" aria-hidden="true" />
                  <span class="p-term">{{ row.term }}</span>
                  <span class="p-value">{{ row.value }}</span>
                </div>
              </div>
              <p v-else class="muted">{{ t('stack.proposalPending') }}</p>
              <div class="proposal-actions">
                <GnButton
                  variant="success"
                  icon="ph-check-circle"
                  :disabled="!task.ai_proposal"
                  @click="approveProposal"
                >
                  {{ t('stack.approveProposal') }}
                </GnButton>
                <GnButton variant="secondary" icon="ph-arrows-counter-clockwise" @click="redetail">
                  {{ t('stack.redetail') }}
                </GnButton>
                <GnButton variant="secondary" icon="ph-pencil-simple-line" @click="doItMyself">
                  {{ t('task.proposalMyself') }}
                </GnButton>
              </div>
            </GnCard>

            <!-- Описание (Markdown): карандаш — правка прямо здесь -->
            <GnCard class="desc-card">
              <template #title>
                <span class="card-title-icon"><i class="ph ph-article" aria-hidden="true" /></span>
                {{ t('task.description') }}
                <GnIconButton
                  v-if="!descEditing"
                  icon="ph-pencil-simple"
                  size="sm"
                  :label="t('task.editDescription')"
                  @click="descEditing = true; draftDescription = task.description"
                />
              </template>
              <div v-if="descEditing" class="inline-desc">
                <MdEditor v-model="draftDescription" caption="" :rows="8" :upload-images="uploadImages" />
                <span class="inline-actions">
                  <GnIconButton
                    icon="ph-check"
                    size="sm"
                    :label="t('common.save')"
                    :disabled="descBusy"
                    @click="saveDescription"
                  />
                  <GnIconButton
                    icon="ph-x"
                    size="sm"
                    :label="t('common.cancel')"
                    :disabled="descBusy"
                    @click="descEditing = false"
                  />
                </span>
              </div>
              <template v-else>
                <div
                  v-if="task.description.trim()"
                  class="md-view"
                  v-html="descriptionHtml"
                  @click="onDescClick"
                />
                <p v-else class="muted">
                  <i class="ph ph-note-blank" aria-hidden="true" />
                  {{ t('task.descriptionEmpty') }}
                </p>
              </template>
            </GnCard>
            <MdLightbox
              v-if="descImage"
              :src="descImage.url"
              :alt="descImage.name"
              @close="descImage = null"
            />

            <!-- Вложения: крупные превью с именами -->
            <GnCard v-if="attachments.length" class="att-card">
              <template #title>
                <span class="card-title-icon">
                  <i class="ph ph-paperclip" aria-hidden="true" />
                </span>
                {{ t('task.attachments') }}
                <GnBadge variant="neutral">{{ attachments.length }}</GnBadge>
              </template>
              <div class="att-grid">
                <a
                  v-for="att in attachments"
                  :key="att.id"
                  class="att-item"
                  :href="api.attachmentUrl(att.id)"
                  target="_blank"
                  rel="noopener"
                >
                  <img :src="api.attachmentUrl(att.id)" :alt="att.original_name" />
                  <span class="att-name" :title="att.original_name">{{ att.original_name }}</span>
                </a>
              </div>
            </GnCard>

            <!-- Подзадачи -->
            <div class="subtasks">
              <div class="subtasks-header">
                <h3 class="subtasks-title">
                  <i class="ph ph-tree-structure" aria-hidden="true" />
                  {{ t('task.subtasks') }}
                </h3>
                <GnButton variant="secondary" size="sm" icon="ph-plus" @click="subtaskOpen = true">
                  {{ t('tree.addSubtask') }}
                </GnButton>
              </div>
              <p v-if="!childTasks.length" class="muted">
                {{ t('task.noSubtasks') }}
              </p>
              <div class="subtask-list">
                <TaskCard
                  v-for="child in childTasks"
                  :key="child.id"
                  :task="child"
                  @status-change="setStatus"
                  @delete="askDelete"
                />
              </div>
            </div>
          </div>

          <!-- Сайдбар: статус, параметры, теги, даты -->
          <aside class="task-side">
            <GnCard class="side-card">
              <template #title>
                <span class="card-title-icon"><i class="ph ph-circle-half" aria-hidden="true" /></span>
                {{ t('task.statusTitle') }}
              </template>
              <div class="status-row">
                <GnBadge :variant="statusVariant(task.status)" class="status-badge">
                  {{ statusLabel(task.status) }}
                </GnBadge>
                <GnDropdown :items="statusMenu(task)">
                  <template #trigger="{ toggle }">
                    <GnIconButton
                      icon="ph-pencil-simple"
                      size="sm"
                      :label="t('task.changeStatus')"
                      @click="toggle"
                    />
                  </template>
                </GnDropdown>
              </div>
              <!-- «Не хочется делать» (ТЗ 3.13): метка «ментально сложная», +10 XP.
                   Переключатель — снять можно до закрытия; после закрытия метка не меняется. -->
              <GnButton
                v-if="task.status !== 'done'"
                size="sm"
                :variant="task.mentally_hard ? 'secondary' : 'warning'"
                icon="ph-brain"
                class="hard-toggle"
                @click="toggleMentallyHard"
              >
                {{ task.mentally_hard ? t('task.mentallyHard.unset') : t('task.mentallyHard.set') }}
              </GnButton>
            </GnCard>

            <GnCard class="side-card">
              <template #title>
                <span class="card-title-icon"><i class="ph ph-list-dashes" aria-hidden="true" /></span>
                {{ t('task.fields') }}
              </template>
              <div class="p-rows">
                <div
                  v-for="row in paramRows"
                  :key="row.key"
                  class="p-row"
                  :class="{ warn: row.warn, editing: row.inline }"
                >
                  <i :class="`ph ${row.icon}`" aria-hidden="true" />
                  <span class="p-term">{{ row.term }}</span>

                  <!-- Инлайн-правка: клик по значению (или «не указано») → редактор -->
                  <template v-if="row.inline">
                    <!-- Приоритет: select -->
                    <InlineField
                      v-if="row.key === 'priority'"
                      :label="t('task.field.priority')"
                      :save="savePriority"
                      @open="draftPriority = task.priority === null ? '' : priorityToGrade(task.priority)"
                    >
                      <template #display="{ edit }">
                        <GnBadge
                          v-if="row.badge"
                          :variant="row.badge"
                          class="p-value-badge"
                          @click="edit"
                        >
                          {{ row.value }}
                        </GnBadge>
                        <span v-else class="p-value empty clickable" @click="edit">{{ t('common.notSet') }}</span>
                      </template>
                      <template #edit="{ save }">
                        <GnSelect
                          v-model="draftPriority"
                          :options="priorityOptionsWithNone"
                          class="inline-select"
                          @change="save"
                        />
                      </template>
                    </InlineField>

                    <!-- Оценка времени: number -->
                    <InlineField
                      v-else-if="row.key === 'estimate'"
                      :label="t('task.field.estimate')"
                      :save="saveEstimate"
                      @open="draftEstimate = task.estimated_minutes"
                    >
                      <template #display="{ edit }">
                        <span class="p-value clickable" :class="{ empty: row.empty }" @click="edit">
                          {{ row.value || t('common.notSet') }}
                        </span>
                      </template>
                      <template #edit="{ save }">
                        <GnInput
                          v-model.number="draftEstimate"
                                                    type="number"
                          min="1"
                          class="inline-num"
                          @keydown.enter="save"
                        />
                      </template>
                    </InlineField>

                    <!-- Фактически: number -->
                    <InlineField
                      v-else-if="row.key === 'actual'"
                      :label="t('task.field.actual')"
                      :save="saveActual"
                      @open="draftActual = task.actual_minutes"
                    >
                      <template #display="{ edit }">
                        <span class="p-value clickable" :class="{ empty: row.empty }" @click="edit">
                          {{ row.value || t('common.notSet') }}
                        </span>
                      </template>
                      <template #edit="{ save }">
                        <GnInput
                          v-model.number="draftActual"
                                                    type="number"
                          min="0"
                          class="inline-num"
                          @keydown.enter="save"
                        />
                      </template>
                    </InlineField>

                    <!-- Деньги: бюджет + оценка затрат одной группой, один PATCH -->
                    <InlineField
                      v-else-if="row.key === 'money'"
                      :label="t('task.field.money')"
                      :save="saveMoney"
                      @open="draftBudget = task.budget_money; draftCost = task.cost_estimate_money"
                    >
                      <template #display="{ edit }">
                        <span class="p-value clickable" :class="{ empty: row.empty }" @click="edit">
                          {{ row.value || t('common.notSet') }}
                        </span>
                      </template>
                      <template #edit="{ save }">
                        <span class="pair-edit">
                          <GnInput
                            v-model.number="draftBudget"
                                                        type="number"
                            min="0"
                            class="inline-num"
                            :aria-label="t('stack.form.budget')"
                            @keydown.enter="save"
                          />
                          <GnInput
                            v-model.number="draftCost"
                                                        type="number"
                            min="0"
                            class="inline-num"
                            :aria-label="t('stack.form.cost')"
                            @keydown.enter="save"
                          />
                        </span>
                      </template>
                    </InlineField>

                    <!-- Дедлайн: строгая дата + нестрогий период одной группой -->
                    <InlineField
                      v-else-if="row.key === 'deadline'"
                      :label="t('task.field.deadline')"
                      :save="saveDeadline"
                      @open="draftDeadlineDate = task.deadline_date ?? ''; draftDeadlinePeriod = task.deadline_period ?? ''"
                    >
                      <template #display="{ edit }">
                        <span class="p-value clickable" :class="{ empty: row.empty }" @click="edit">
                          {{ row.value || t('common.notSet') }}
                        </span>
                      </template>
                      <template #edit="{ save }">
                        <span class="pair-edit">
                          <GnInput
                            v-model="draftDeadlineDate"
                            type="date"
                            class="inline-date"
                            :aria-label="t('stack.deadline.date')"
                            @keydown.enter="save"
                          />
                          <GnSelect
                            v-model="draftDeadlinePeriod"
                            :options="deadlinePeriodsWithNone"
                            class="inline-select"
                            :aria-label="t('stack.deadline.period')"
                          />
                        </span>
                      </template>
                    </InlineField>
                  </template>

                  <!-- Read-only строки (тип задачи) и бейдж приоритета без правки -->
                  <template v-else>
                    <GnBadge v-if="row.badge" :variant="row.badge">{{ row.value }}</GnBadge>
                    <span v-else class="p-value">{{ row.value }}</span>
                  </template>
                </div>
              </div>
            </GnCard>

            <GnCard class="side-card">
              <template #title>
                <span class="card-title-icon"><i class="ph ph-tag" aria-hidden="true" /></span>
                {{ t('stack.form.tags') }}
              </template>
              <!-- Быстрое добавление/удаление тегов: новое имя создаёт тег -->
              <GnTagInput
                v-model="tagNames"
                :placeholder="t('task.addTag')"
                unique
                @add="onTagAdd"
                @remove="onTagRemove"
              />
            </GnCard>

            <GnCard class="side-card">
              <template #title>
                <span class="card-title-icon"><i class="ph ph-calendar-blank" aria-hidden="true" /></span>
                {{ t('task.dates') }}
              </template>
              <div class="p-rows">
                <div v-for="row in dateRows" :key="row.key" class="p-row">
                  <i :class="`ph ${row.icon}`" aria-hidden="true" />
                  <span class="p-term">{{ row.term }}</span>
                  <span class="p-value">{{ row.value }}</span>
                </div>
              </div>
            </GnCard>
          </aside>
        </div>
      </template>
    </template>

    <GnDrawer v-model:open="subtaskOpen" :title="t('tree.promptSubtask')" position="right">
      <TaskForm
        v-if="subtaskOpen && task"
        :key="task.id"
        :parent-id="task.id"
        @saved="onSubtaskSaved"
        @cancel="subtaskOpen = 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>
.task-skeleton {
  margin-bottom: 1rem;
}
/* шапка карточки описания: текст слева, карандаш справа в цвет заголовка */
.desc-card .card-title {
  display: flex;
  align-items: center;
}
.desc-card .card-title .btn-icon {
  margin-left: auto;
  margin-top: -23px;
  color: inherit; /* тёмный цвет текста заголовка — на светлой полосе шапки */
  width: 24px;
  height: 24px;
  font-size: 16px;
}
.status-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75rem;
}
.status-badge {
  font-size: 0.95em;
}
/* кнопка «не хочется делать» под строкой статуса */
.hard-toggle {
  margin-top: 0.75rem;
  width: 100%;
}
/* чипы-переходы в шапке (родитель, проект) */
.meta-link {
  text-decoration: none;
}
.meta-link:hover .badge {
  color: var(--accent, #7aa2f7);
  border-color: var(--accent, #7aa2f7);
}
/* Две колонки на широком экране: содержимое + сайдбар параметров */
.task-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 340px;
  gap: 1.5rem;
  align-items: start;
}
@media (max-width: 960px) {
  .task-grid {
    grid-template-columns: 1fr;
  }
}
.task-main > * + *,
.task-side > * + * {
  margin-top: 1.5rem;
}
.proposal-card,
.desc-card,
.att-card {
  margin-bottom: 0;
}
.proposal-hint {
  margin-top: -0.25rem;
}
.proposal-rows,
.p-rows {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
}
.p-row {
  display: flex;
  gap: 0.6rem;
  align-items: baseline;
  font-size: 0.95rem;
}
.p-row > i {
  flex: 0 0 1.25rem;
  text-align: center;
  color: var(--accent, #7aa2f7);
  font-size: 1.05em;
  align-self: center;
}
.p-term {
  color: var(--text-muted, #888);
  flex: 0 0 auto;
  min-width: 7.5rem;
}
.p-value {
  flex: 1 1 auto;
  overflow-wrap: anywhere;
}
.p-row.warn > i,
.p-row.warn .p-value {
  color: var(--danger, #f7768e);
}
/* строки с инлайн-правкой: редактору нужен center, а не baseline */
.p-row.editing {
  align-items: center;
}
/* кликабельное значение параметра: курсор + подсветка по hover */
.p-value.clickable {
  cursor: pointer;
}
.p-value.clickable:hover {
  color: var(--accent, #7aa2f7);
}
.p-value.empty {
  color: var(--text-muted, #888);
  font-weight: normal;
}
.p-value-badge {
  cursor: pointer;
}
/* Инлайн-редакторы: GnInput/GnSelect рендерят обёртку .form-group кита —
   в строке параметров убираем вертикальные отступы формы и ужимаем поле
   (переопределение размеров, стили кита остаются). Внутренние элементы
   компонента — только через :deep. */
.p-row .form-group {
  margin: 0;
  min-width: 0;
}
.p-row :deep(.form-group .label) {
  margin: 0;
}
.p-row :deep(.form-group .label .input) {
  margin-top: 0;
  min-height: 0;
  padding: 4px 10px;
  border-bottom-width: 2px;
  font-size: 14px;
}
/* ширина: класс из attrs вешается на сам input внутри компонента */
.p-row :deep(input.inline-num) {
  width: 4.5rem;
}
.p-row :deep(input.inline-date) {
  width: 8.5rem;
}
.pair-edit {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  align-items: center;
}
.pair-edit .form-group {
  flex: 1 1 4.5rem;
}
.p-row :deep(.inline-select) {
  min-width: 7rem;
}
/* стрелка/иконочный отступ кита в инлайне не нужен */
.p-row :deep(.select-wrap .select) {
  padding-left: 10px;
}
/* заголовок страницы как кнопка правки */
.title-editable {
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  color: inherit;
  text-align: inherit;
  cursor: pointer;
  overflow-wrap: anywhere;
}
.title-editable:hover {
  color: var(--accent, #7aa2f7);
}
/* правка заголовка: поле кита в h1 — наследует шрифт, занимает строку шапки */
/* правка заголовка: поле кита в h1 — наследует шрифт, на всю ширину шапки */
.page-header .inline-field {
  width: 100%;
}
.page-header :deep(.inline-editing) {
  width: 100%;
  flex-wrap: nowrap; /* редактор и кнопки в одной строке шапки */
}
.page-header .form-group {
  margin: 0;
  flex: 1 1 auto;
  min-width: 0;
}
.page-header :deep(.form-group .label) {
  margin: 0;
}
.page-header :deep(input.title-input) {
  font: inherit;
  font-weight: inherit;
  min-height: 0;
  padding: 4px 12px;
  margin-top: 0;
}
/* правка описания: редактор + кнопки */
.inline-desc {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.inline-desc .inline-actions {
  display: flex;
  gap: 0.25rem;
}
.proposal-actions {
  margin-top: 1rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
}
.muted {
  color: var(--text-muted, #888);
  font-size: 0.9em;
}
.md-view {
  overflow-x: auto;
  font-size: 1.02rem;
  line-height: 1.65;
}
.side-card {
  margin-bottom: 0;
}
.side-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}
/* Вложения: сетка крупных превью с подписями */
.att-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 1rem;
}
.att-item {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  text-decoration: none;
}
.att-item img {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  border-radius: 8px;
  border: 1px solid var(--border, #333);
  display: block;
}
.att-name {
  font-size: 0.85em;
  color: var(--text-muted, #888);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.att-item:hover .att-name {
  color: var(--accent, #7aa2f7);
}
.subtasks-header {
  display: flex;
  gap: 0.75rem;
  align-items: center;
  justify-content: space-between; /* заголовок слева, кнопка справа */
  margin-bottom: 0.75rem;
}
.subtasks-title {
  margin: 0;
  font-size: 1.1rem;
}
.subtasks-title i {
  color: var(--accent, #7aa2f7);
  margin-right: 0.35rem;
}
/* визуальное отделение сабтасков от основного таска */
.subtask-list {
  margin-top: 1.5rem;
}
</style>