<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-tray"
: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: center;
margin: 1.75rem 0;
}
.quick-add :deep(.form-group) {
flex: 1 1 16rem;
margin-bottom: 0;
}
.quick-add :deep(.input) {
height: 3.75rem;
font-size: 1.1rem;
}
.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>