Newer
Older
gnexus-tasks / frontend / src / components / Dashboard.vue
<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>