<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 Tag, type Task } from '../api'
import {
deadlineLabel,
deadlineOverdue,
formatDate,
formatMinutes,
formatMoney,
priorityLabel,
priorityVariant,
recurrenceLabel,
renderMarkdown,
statusOptions,
statusLabel,
statusVariant,
} from '../taskui'
import { useToast } from 'gnexus-ui-kit/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) : '',
)
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)
// Сайдбар «Параметры» — строка = иконка + термин + значение
const paramRows = computed(() => {
const task0 = task.value
if (!task0) return []
const rows: { key: string; icon: string; term: string; value: string; warn?: boolean; badge?: string }[] = []
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'),
})
if (task0.priority !== null)
rows.push({
key: 'priority',
icon: 'ph-flag',
term: t('task.field.priority'),
value: priorityLabel(task0.priority),
badge: priorityVariant(task0.priority),
})
if (task0.estimated_minutes)
rows.push({
key: 'estimate',
icon: 'ph-clock',
term: t('task.field.estimate'),
value: `≈ ${formatMinutes(task0.estimated_minutes)}`,
})
if (task0.actual_minutes)
rows.push({
key: 'actual',
icon: 'ph-timer',
term: t('task.field.actual'),
value: formatMinutes(task0.actual_minutes),
})
if (task0.budget_money !== null || task0.cost_estimate_money !== null)
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(' · '),
})
const deadline = deadlineLabel(task0)
if (deadline)
rows.push({
key: 'deadline',
icon: 'ph-alarm',
term: t('task.field.deadline'),
value: deadline,
warn: deadlineOverdue(task0) && task0.status !== 'done' && task0.status !== 'cancelled',
})
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 })
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
tagNames.value = fresh.tags.map((t0) => t0.name)
}
// Подзадачи: прямые дети текущей задачи, карточками
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="gntodo" :title="task.title">
<template #meta>
<TaskBadges :task="task" />
</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>
</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') }}
</template>
<div v-if="task.description.trim()" class="md-view" v-html="descriptionHtml" />
<p v-else class="muted">
<i class="ph ph-note-blank" aria-hidden="true" />
{{ t('task.descriptionEmpty') }}
</p>
</GnCard>
<!-- Вложения: крупные превью с именами -->
<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 }">
<GnButton size="sm" variant="secondary" icon="ph-swap" @click="toggle">
{{ t('task.changeStatus') }}
</GnButton>
</template>
</GnDropdown>
</div>
</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 v-if="paramRows.length" class="p-rows">
<div v-for="row in paramRows" :key="row.key" class="p-row" :class="{ warn: row.warn }">
<i :class="`ph ${row.icon}`" aria-hidden="true" />
<span class="p-term">{{ row.term }}</span>
<GnBadge v-if="row.badge" :variant="row.badge">{{ row.value }}</GnBadge>
<span v-else class="p-value">{{ row.value }}</span>
</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;
}
.status-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.status-badge {
font-size: 0.95em;
}
/* Две колонки на широком экране: содержимое + сайдбар параметров */
.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;
}
.card-title-icon {
margin-right: 0.4rem;
color: var(--accent, #7aa2f7);
}
.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);
}
.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;
margin-bottom: 0.75rem;
}
.subtasks-title {
margin: 0;
font-size: 1.1rem;
}
.subtasks-title i {
color: var(--accent, #7aa2f7);
margin-right: 0.35rem;
}
</style>