<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>