<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Project, type Task } from '../api'
import { intlLocale } from '../i18n/locale'
import { proposalItems } from '../taskui'
import { useToast } from 'gnexus-ui-kit/vue'
import TaskForm from '../components/TaskForm.vue'
import Dashboard from '../components/Dashboard.vue'
// Главная страница: дашборд активных задач сверху + стек входящих ниже.
// LLM-предложение — черновик: «Да, всё верно» применяет его, иначе правим вручную
// (детализация — TaskForm в drawer'е, сохранение там же утверждает).
const { t } = useI18n()
const toast = useToast()
// Все задачи один раз: стек и дашборд — разные срезы одного списка
const tasks = ref<Task[]>([])
const projects = ref<Project[]>([])
const quickTitle = ref('')
const error = ref('')
const loading = ref(false)
const stack = computed(() => tasks.value.filter((task) => task.detail_state === 'raw'))
const editing = ref<Task | null>(null)
const drawerOpen = ref(false)
let pollTimer: number | undefined
let loaded = false
async function loadAll(silent = false) {
// silent-опрос (фоновые тики) не включает скелетон и не заменяет массивы,
// если данные не изменились — иначе страница дёргается каждые несколько секунд
if (!silent || !loaded) loading.value = true
try {
const [taskList, projectList] = await Promise.all([api.listTasks(), api.listProjects()])
if (JSON.stringify(taskList) !== JSON.stringify(tasks.value)) tasks.value = taskList
if (JSON.stringify(projectList) !== JSON.stringify(projects.value)) projects.value = projectList
loaded = true
} catch (e) {
error.value = String(e)
} finally {
loading.value = false
}
}
// Опрос: LLM детализирует задачу в фоне — предложение появится через секунды
function startPolling() {
pollTimer = window.setInterval(async () => {
if (!editing.value) await loadAll(true)
}, 5000)
}
onMounted(async () => {
await loadAll()
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 loadAll()
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
}
}
// Редактирование — в боковой панели (GnDrawer), форма сама сохраняет и утверждает
function startEdit(task: Task) {
editing.value = task
drawerOpen.value = true
}
function closeDrawer() {
drawerOpen.value = false
editing.value = null
}
async function onSaved() {
closeDrawer()
await loadAll()
}
// Новая задача — полная форма (quick-add — просто опция)
const createOpen = ref(false)
function onCreated() {
createOpen.value = false
void loadAll()
}
async function approve(task: Task) {
try {
// «Да, всё верно» — принять предложение автодетализации
await api.approveTask(task.id, task.ai_proposal !== null)
toast.success({ title: t('common.taskApproved') })
await loadAll()
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
}
}
async function redetail(task: Task) {
try {
await api.redetailTask(task.id)
await loadAll()
} 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 loadAll()
} 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) },
]
}
// Сниппет карточки: описание (предложение ИИ и так показано бейджами ниже)
function snippet(task: Task): string {
return task.description.trim()
}
// Счётчики шапки: N в стеке · N активных
const activeCount = computed(
() =>
tasks.value.filter(
(task) =>
task.detail_state === 'approved' && task.status !== 'done' && task.status !== 'cancelled',
).length,
)
</script>
<template>
<section>
<GnPageHeader kicker="GNexus Tasks" :title="t('home.title')">
<template #meta>
<GnBadge variant="info"><i class="ph ph-tray" aria-hidden="true" />{{ t('home.stackCount', { n: stack.length }) }}</GnBadge>
<GnBadge variant="accent"><i class="ph ph-circle-half" aria-hidden="true" />{{ t('home.activeCount', { n: activeCount }) }}</GnBadge>
</template>
<template #actions>
<GnButton variant="primary" icon="ph-plus" @click="createOpen = true">
{{ t('stack.newTask') }}
</GnButton>
</template>
</GnPageHeader>
<GnAlert v-if="error" variant="error">{{ error }}</GnAlert>
<!-- Дашборд: активные задачи, просрочка, дедлайны, проекты -->
<Dashboard :tasks="tasks" :projects="projects" />
<!-- Стек входящих -->
<div class="stack-section-title">
<i class="ph ph-tray" aria-hidden="true" />
<span>{{ t('nav.stack') }}</span>
<GnBadge variant="info">{{ stack.length }}</GnBadge>
</div>
<!-- Быстрый ввод: многострочное поле (Enter — закинуть, Shift+Enter — перенос) -->
<form class="quick-add" @submit.prevent="quickAdd">
<GnTextarea
v-model="quickTitle"
:rows="1"
:placeholder="t('stack.quickPlaceholder')"
@keydown.enter.exact.prevent="quickAdd"
/>
<GnButton type="submit" variant="primary" icon="ph-arrow-circle-down">
{{ t('stack.quickAdd') }}
</GnButton>
</form>
<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>
<RouterLink class="card-title" :to="`/tasks/${task.id}`">{{ task.title }}</RouterLink>
</template>
<p v-if="snippet(task)" class="snippet">{{ snippet(task) }}</p>
<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">
<i :class="`ph ${item.icon}`" aria-hidden="true" />{{ item.label }}
</GnBadge>
</div>
<div v-else class="proposal">
<span class="muted">{{ t('stack.proposalPending') }}</span>
</div>
<div class="task-actions">
<GnButton
variant="success"
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">
<TaskForm
v-if="editing"
:key="editing.id"
:task="editing"
:approve-after-save="true"
:submit-label="t('stack.approve')"
@saved="onSaved"
@cancel="closeDrawer"
/>
</GnDrawer>
<GnDrawer v-model:open="createOpen" :title="t('stack.newTask')" position="right">
<TaskForm @saved="onCreated" @cancel="createOpen = 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>
/* Подпись секции стека: дашборд и стек — два блока одной главной */
.stack-section-title {
display: flex;
gap: 0.6rem;
align-items: center;
margin: 0 0 0.25rem;
font-weight: 600;
font-size: 1.05rem;
}
.stack-section-title > i {
color: var(--accent, #7aa2f7);
}
/* Важная формочка: вертикальная компоновка, поле на всю ширину контейнера,
увеличенные вертикальные отступы отделяют её от заголовка и списка задач */
.quick-add {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
margin: 1.75rem 0;
}
.quick-add :deep(.form-group) {
width: 100%;
/* у .form-group кита max-width:600px — снимаем: поле на всю ширину контейнера */
max-width: 100%;
margin-bottom: 0;
}
.quick-add :deep(.input) {
height: 3.75rem;
font-size: 1.1rem;
line-height: 3.4rem;
}
.stack-skeleton {
margin-bottom: 1rem;
}
.task-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card-title {
color: inherit;
text-decoration: none;
}
.card-title:hover {
color: var(--accent, #7aa2f7);
}
.snippet {
margin: 0 0 0.75rem;
color: var(--text-muted, #a9b1d6);
font-size: 0.9em;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.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;
}
</style>