Newer
Older
gnexus-tasks / frontend / src / views / ProjectsView.vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useToast } from 'gnexus-ui-kit/vue'
import { api, type Project, type Task } from '../api'
import PriorityBadge from '../components/PriorityBadge.vue'
import { notifyCreated } from '../gamification'
import { useRealtimeRefresh } from '../realtime'
import {
  relevanceLabel,
  renderMarkdown,
} from '../taskui'
import QuickPrompt from '../components/QuickPrompt.vue'
import Pagination from '../components/Pagination.vue'

// Проекты (ТЗ 3.7): актуальность, приоритет, Markdown-заметка с ресурсами.
// Заголовок карточки — ссылка на страницу проекта; на карточке — счётчик задач.

const { t } = useI18n()
const router = useRouter()
const toast = useToast()

// Свёрнутость заметок на карточках: id проектов с развёрнутой заметкой
const expandedNotes = ref(new Set<number>())

function toggleNote(id: number): void {
  const next = new Set(expandedNotes.value)
  if (next.has(id)) next.delete(id)
  else next.add(id)
  expandedNotes.value = next
}

const projects = ref<Project[]>([])
const tasks = ref<Task[]>([])
const error = ref('')
const loading = ref(false)

// Счётчики задач по проектам: всего / завершено
const taskCounts = computed(() => {
  const map = new Map<number, { total: number; done: number }>()
  tasks.value.forEach((task) => {
    if (!task.project) return
    const entry = map.get(task.project.id) ?? { total: 0, done: 0 }
    entry.total += 1
    if (task.status === 'done') entry.done += 1
    map.set(task.project.id, entry)
  })
  return map
})

// Параметры для бейджа карточки: всего / закрыто / открыто
function countsOf(id: number): { total: number; done: number } {
  return taskCounts.value.get(id) ?? { total: 0, done: 0 }
}

async function load() {
  loading.value = true
  error.value = ''
  try {
    ;[projects.value, tasks.value] = await Promise.all([api.listProjects(), api.listTasks()])
  } catch (e) {
    error.value = String(e)
  } finally {
    loading.value = false
  }
}

onMounted(load)

// Реактивность (ТЗ 3.14): счётчики зависят от задач, карточки — от проектов
useRealtimeRefresh(load, ['project.changed', 'project.deleted', 'task.changed', 'task.deleted'])

// Пагинация: по 30 проектов на страницу
const page = ref(1)
const pagedProjects = computed(() =>
  projects.value.slice((page.value - 1) * 30, page.value * 30),
)


// Создание — модальное окно вместо window.prompt
const createOpen = ref(false)

async function createProject(name: string) {
  try {
    await api.createProject(name)
    toast.success({ title: t('common.created') })
    // наградный тост после основного (провайдер кита держит один тост)
    notifyCreated()
    await load()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

</script>

<template>
  <section>
    <GnPageHeader kicker="GNexus Tasks" :title="t('projects.title')">
      <template #meta>{{ t('projects.count', { n: projects.length }) }}</template>
      <template #actions>
        <GnButton variant="primary" icon="ph-plus" @click="createOpen = true">
          {{ t('projects.new') }}
        </GnButton>
      </template>
    </GnPageHeader>

    <GnAlert v-if="error" variant="error">{{ error }}</GnAlert>

    <GnSkeleton v-if="loading && projects.length === 0" type="block" stack :count="3" class="list-skeleton" />

    <GnEmptyState
      v-if="!loading && projects.length === 0"
      icon="ph-folders"
      :title="t('projects.emptyTitle')"
      :text="t('projects.emptyText')"
    >
      <template #actions>
        <GnButton variant="primary" icon="ph-plus" @click="createOpen = true">
          {{ t('projects.new') }}
        </GnButton>
      </template>
    </GnEmptyState>

    <div class="project-list">
      <GnCard v-for="project in pagedProjects" :key="project.id" class="project-card">
        <template #title>
          <RouterLink class="card-link" :to="`/projects/${project.id}`">{{ project.name }}</RouterLink>
        </template>

        <div class="project-meta">
          <GnBadge variant="info"><i class="ph ph-activity" aria-hidden="true" />
            {{ relevanceLabel(project.relevance_status) }}
          </GnBadge>
          <PriorityBadge v-if="project.priority !== null" :priority="project.priority" />
          <GnBadge variant="neutral"><i class="ph ph-list-checks" aria-hidden="true" />
            {{ t('projects.taskCounts', countsOf(project.id)) }}
          </GnBadge>
        </div>
        <div v-if="project.note" class="note-wrap">
          <div
            class="md-preview note"
            :class="{ collapsed: !expandedNotes.has(project.id) }"
            v-html="renderMarkdown(project.note)"
          />
          <button class="note-toggle" type="button" @click="toggleNote(project.id)">
            <i
              :class="expandedNotes.has(project.id) ? 'ph ph-caret-up' : 'ph ph-caret-down'"
              aria-hidden="true"
            />
            {{ expandedNotes.has(project.id) ? t('common.collapse') : t('common.expand') }}
          </button>
        </div>
        <div class="form-actions">
          <GnButton
            variant="secondary"
            size="sm"
            icon="ph-arrow-square-out"
            @click="router.push(`/projects/${project.id}`)"
          >
            {{ t('common.open') }}
          </GnButton>
        </div>
      </GnCard>
    </div>

    <Pagination v-model:page="page" :total="projects.length" />

    <QuickPrompt
      v-model:open="createOpen"
      :title="t('projects.prompt')"
      :label="t('projects.promptLabel')"
      :placeholder="t('projects.prompt')"
      :confirm-text="t('common.create')"
      @confirm="createProject"
    />

  </section>
</template>

<style scoped>
.project-list {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}
.card-link {
  color: inherit;
  text-decoration: none;
}
.card-link:hover {
  color: var(--accent, #7aa2f7);
}
.list-skeleton {
  margin-bottom: 1rem;
}
.project-meta {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 0.75rem;
}
.edit-form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}
.form-actions {
  display: flex;
  gap: 0.75rem;
  align-items: center;
}
.md-preview {
  border: 1px solid var(--border, #333);
  padding: 0.75rem;
  overflow-x: auto;
}
.md-preview :deep(img) {
  max-width: 100%;
  height: auto;
}
.note-wrap {
  margin-bottom: 0.75rem;
}
/* Заметка по умолчанию свёрнута до нескольких строк */
.note.collapsed {
  max-height: 5.5em;
  overflow: hidden;
}
.note-toggle {
  display: flex;
  gap: 0.3rem;
  align-items: center;
  margin-top: 0.25rem;
  padding: 0;
  border: none;
  background: none;
  color: var(--accent, #7aa2f7);
  font: inherit;
  font-size: 0.85em;
  cursor: pointer;
}
</style>