<script setup lang="ts">
// Дашборд на главной (стек + дашборд совмещены): активные задачи по секциям —
// «В работе», «Просрочено», дедлайны недели, «Следующие» — плюс сводка
// по статусам и активным проектам в сайдбаре. Данные приходят от родителя
// (StackView грузит все задачи один раз и фильтрует стек).
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Project, Task } from '../api'
import {
deadlineLabel,
deadlineOverdue,
priorityLabel,
priorityVariant,
} from '../taskui'
const props = defineProps<{
tasks: Task[]
projects: Project[]
}>()
const { t } = useI18n()
// Активные = утверждённые и не закрытые (raw/detailed живут в стеке)
const openApproved = computed(() =>
props.tasks.filter(
(task) => task.detail_state === 'approved' && task.status !== 'done' && task.status !== 'cancelled',
),
)
const inProgress = computed(() => openApproved.value.filter((task) => task.status === 'in_progress'))
const overdue = computed(() => openApproved.value.filter((task) => deadlineOverdue(task)))
// Дедлайны с конкретной датой в ближайшую неделю (просроченные уже в своей секции)
function withinWeek(task: Task): boolean {
if (!task.deadline_date || deadlineOverdue(task)) return false
const limit = new Date()
limit.setDate(limit.getDate() + 7)
return task.deadline_date <= limit.toISOString().slice(0, 10)
}
const upcoming = computed(() => openApproved.value.filter(withinWeek))
// «Следующие»: к выполнению, по убыванию приоритета (без приоритета — в конце)
const priority = (task: Task) => task.priority ?? -1
const next = computed(() =>
openApproved.value
.filter((task) => task.status === 'to_do')
.sort((a, b) => priority(b) - priority(a))
.slice(0, 5),
)
const sections = computed(() =>
[
{ key: 'inProgress', icon: 'ph-circle-half', variant: 'accent', tasks: inProgress.value },
{ key: 'overdue', icon: 'ph-alarm', variant: 'error', tasks: overdue.value },
{ key: 'deadlines', icon: 'ph-calendar-check', variant: 'warning', tasks: upcoming.value },
{ key: 'next', icon: 'ph-list-checks', variant: 'neutral', tasks: next.value },
].filter((s) => s.tasks.length > 0),
)
// Сводка: объём и результативность. Стек/в работе не дублируем — они и так
// видны в секциях дашборда и шапке страницы.
const overviewRows = computed(() => {
const all = props.tasks
const monthAgo = new Date()
monthAgo.setDate(monthAgo.getDate() - 30)
const doneTasks = all.filter((task) => task.status === 'done')
const doneMonth = doneTasks.filter(
(task) => task.done_at && new Date(task.done_at) >= monthAgo,
).length
const open = all.filter((task) => task.status !== 'done' && task.status !== 'cancelled')
const rows: { key: string; icon: string; term: string; value: number; warn?: boolean }[] = [
{ key: 'total', icon: 'ph-stack', term: t('home.totalTasks'), value: all.length },
{ key: 'doneMonth', icon: 'ph-check-circle', term: t('home.doneMonth'), value: doneMonth },
{ key: 'doneAll', icon: 'ph-trophy', term: t('home.doneAll'), value: doneTasks.length },
{
key: 'deferred',
icon: 'ph-pause-circle',
term: t('taskui.status.deferred'),
value: open.filter((task) => task.status === 'deferred').length,
},
]
if (overdue.value.length)
rows.push({
key: 'overdue',
icon: 'ph-alarm',
term: t('home.overdue'),
value: overdue.value.length,
warn: true,
})
return rows
})
// Активные проекты: сколько открыто и сколько сделано всего
const projectRows = computed(() =>
props.projects
.filter((project) => project.relevance_status === 'active')
.map((project) => {
const list = props.tasks.filter(
(task) => task.project?.id === project.id && task.detail_state === 'approved',
)
const done = list.filter((task) => task.status === 'done').length
const open = list.filter((task) => task.status !== 'done' && task.status !== 'cancelled').length
return { project, open, done, total: list.length }
})
.filter((row) => row.total > 0)
.sort((a, b) => b.open - a.open),
)
const hasAnything = computed(() => sections.value.length > 0 || projectRows.value.length > 0)
</script>
<template>
<div class="dash">
<GnEmptyState
v-if="!hasAnything"
icon="ph-sun"
:title="t('home.clearTitle')"
:text="t('home.clearText')"
/>
<div v-else class="dash-grid">
<div class="dash-main">
<GnCard v-for="section in sections" :key="section.key" class="dash-card">
<template #title>
<span class="card-title-icon"><i :class="`ph ${section.icon}`" aria-hidden="true" /></span>
{{ t(`home.${section.key}`) }}
<GnBadge :variant="section.variant">{{ section.tasks.length }}</GnBadge>
</template>
<div class="dash-tasks">
<div v-for="task in section.tasks" :key="`${section.key}-${task.id}`" class="dash-task">
<RouterLink class="dash-task-title" :to="`/tasks/${task.id}`">{{ task.title }}</RouterLink>
<div class="dash-task-badges">
<GnBadge v-if="task.priority !== null" :variant="priorityVariant(task.priority)">
<i class="ph ph-flag" aria-hidden="true" />{{ priorityLabel(task.priority) }}
</GnBadge>
<GnBadge
v-if="deadlineLabel(task)"
:variant="deadlineOverdue(task) ? 'error' : 'warning'"
>
<i class="ph ph-alarm" aria-hidden="true" />{{ deadlineLabel(task) }}
</GnBadge>
<GnBadge v-if="task.project" variant="neutral">
<i class="ph ph-folder" aria-hidden="true" />{{ task.project.name }}
</GnBadge>
<GnBadge v-if="task.task_type === 'recurring'" variant="info">
<i class="ph ph-repeat" aria-hidden="true" />{{ t('taskui.recur.recurring') }}
</GnBadge>
</div>
</div>
</div>
</GnCard>
</div>
<aside class="dash-side">
<GnCard class="dash-card">
<template #title>
<span class="card-title-icon"><i class="ph ph-chart-bar" aria-hidden="true" /></span>
{{ t('home.overview') }}
</template>
<div class="dash-rows">
<div v-for="row in overviewRows" :key="row.key" class="dash-row" :class="{ warn: row.warn }">
<i :class="`ph ${row.icon}`" aria-hidden="true" />
<span class="dash-term">{{ row.term }}</span>
<GnBadge variant="neutral">{{ row.value }}</GnBadge>
</div>
</div>
</GnCard>
<GnCard v-if="projectRows.length" class="dash-card">
<template #title>
<span class="card-title-icon"><i class="ph ph-folders" aria-hidden="true" /></span>
{{ t('home.projects') }}
</template>
<div class="dash-rows">
<RouterLink
v-for="row in projectRows"
:key="row.project.id"
class="dash-row proj-row"
:to="`/projects/${row.project.id}`"
>
<i class="ph ph-folder" aria-hidden="true" />
<span class="dash-term">{{ row.project.name }}</span>
<GnBadge variant="neutral" :title="t('projectPage.progress', { done: row.done, total: row.total })">
<i class="ph ph-check" aria-hidden="true" />{{ row.done }}/{{ row.total }}
</GnBadge>
<GnBadge v-if="row.open" variant="info">{{ row.open }}</GnBadge>
</RouterLink>
</div>
</GnCard>
</aside>
</div>
</div>
</template>
<style scoped>
.dash-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 1.5rem;
align-items: start;
margin-bottom: 2rem;
}
@media (max-width: 960px) {
.dash-grid {
grid-template-columns: 1fr;
}
}
.dash-main > * + *,
.dash-side > * + * {
margin-top: 1.5rem;
}
.dash-tasks {
display: flex;
flex-direction: column;
}
.dash-task {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.75rem;
align-items: center;
padding: 0.55rem 0;
}
.dash-task + .dash-task {
border-top: 1px solid var(--border, #2a2f45);
}
.dash-task-title {
font-weight: 600;
color: inherit;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.dash-task-title:hover {
color: var(--accent, #7aa2f7);
}
.dash-task-badges {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
}
.dash-rows {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.dash-row {
display: flex;
gap: 0.6rem;
align-items: center;
font-size: 0.95rem;
}
.dash-row > i {
flex: 0 0 1.25rem;
text-align: center;
color: var(--accent, #7aa2f7);
font-size: 1.05em;
}
.dash-term {
color: var(--text-muted, #888);
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dash-row.warn > i {
color: var(--danger, #f7768e);
}
.proj-row {
text-decoration: none;
color: inherit;
}
.proj-row:hover .dash-term {
color: var(--accent, #7aa2f7);
}
</style>