Newer
Older
gnexus-tasks / frontend / src / views / ProjectView.vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToast } from 'gnexus-ui-kit/vue'
import { api, ApiError, type Project, type Tag, type Task } from '../api'
import { relevanceLabel, renderMarkdown, statusOptions } from '../taskui'
import QuickPrompt from '../components/QuickPrompt.vue'
import TaskCard from '../components/TaskCard.vue'
import TaskTree from '../components/TaskTree.vue'

defineOptions({ name: 'ProjectView' })

// Страница проекта: заметка, прогресс, список задач с фильтрами и дерево подзадач.
// Создание задачи здесь = createTask + PATCH project_id (TaskCreate не принимает
// project_id) — задача попадёт в стек на детализацию.

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

const projectId = computed(() => Number(route.params.id))
const project = ref<Project | null>(null)
const tasks = ref<Task[]>([])
const tags = ref<Tag[]>([])
const error = ref('')
const loading = ref(false)
const notFound = ref(false)

// Фильтры списка: статус, тег
const filters = ref({ status: '', tag_id: '' })

const filteredTasks = computed(() =>
  tasks.value.filter(
    (task) =>
      (!filters.value.status || task.status === filters.value.status) &&
      (!filters.value.tag_id || task.tags.some((tag) => String(tag.id) === filters.value.tag_id)),
  ),
)

// Прогресс по всем задачам проекта (фильтры не влияют)
const doneCount = computed(() => tasks.value.filter((task) => task.status === 'done').length)

const noteHtml = computed(() => (project.value ? renderMarkdown(project.value.note) : ''))

async function load() {
  loading.value = true
  notFound.value = false
  error.value = ''
  try {
    project.value = await api.getProject(projectId.value)
    tasks.value = await api.listTasks({ project_id: projectId.value })
    tags.value = await api.listTags()
  } catch (e) {
    if (e instanceof ApiError && e.status === 404) {
      notFound.value = true
    } else {
      error.value = String(e)
    }
  } finally {
    loading.value = false
  }
}

onMounted(load)

// Правка проекта — drawer, как в списке проектов
const drawerOpen = ref(false)
const editForm = ref({
  name: '',
  relevance_status: 'active',
  priority: null as number | null,
  note: '',
})
const showPreview = ref(false)

const relevanceOptions = computed(() =>
  ['active', 'paused', 'archived'].map((value) => ({ value, label: relevanceLabel(value) })),
)

const notePreview = computed(() => renderMarkdown(editForm.value.note))

function startEdit() {
  if (!project.value) return
  editForm.value = {
    name: project.value.name,
    relevance_status: project.value.relevance_status,
    priority: project.value.priority,
    note: project.value.note,
  }
  showPreview.value = false
  drawerOpen.value = true
}

function closeDrawer() {
  drawerOpen.value = false
}

async function saveEdit() {
  if (!project.value) return
  try {
    await api.updateProject(project.value.id, { ...editForm.value })
    toast.success({ title: t('common.saved') })
    closeDrawer()
    await load()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

async function setStatus(_task: Task, status: string) {
  error.value = ''
  try {
    await api.updateTask(_task.id, { status })
    await load()
  } catch (e) {
    error.value = 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 load()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

// Новая задача проекта: два запроса (create + PATCH); попадёт в стек на детализацию
const newTaskOpen = ref(false)

async function createTaskForProject(title: string) {
  newTaskOpen.value = false
  try {
    const created = await api.createTask(title)
    await api.updateTask(created.id, { project_id: projectId.value })
    toast.info({ title: t('projectPage.taskCreated') })
    await load()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

const statusOptionsWithAny = computed(() => [
  { value: '', label: t('common.anyStatus') },
  ...statusOptions(),
])
</script>

<template>
  <section>
    <GnSkeleton v-if="loading && !project" type="block" stack :count="2" class="page-skeleton" />

    <GnEmptyState
      v-if="!loading && notFound"
      icon="ph-folder-simple"
      :title="t('projectPage.notFoundTitle')"
      :text="t('projectPage.notFoundText')"
    >
      <template #actions>
        <GnButton variant="primary" icon="ph-folders" @click="router.push('/projects')">
          {{ t('nav.projects') }}
        </GnButton>
      </template>
    </GnEmptyState>

    <template v-if="project">
      <GnPageHeader kicker="gntodo" :title="project.name">
        <template #meta>
          <GnBadge variant="info">{{ relevanceLabel(project.relevance_status) }}</GnBadge>
          <GnBadge v-if="project.priority !== null" variant="warning">P{{ project.priority }}</GnBadge>
        </template>
        <template #actions>
          <GnButton variant="secondary" icon="ph-pencil-simple" @click="startEdit">
            {{ t('projects.edit') }}
          </GnButton>
          <GnButton variant="primary" icon="ph-plus" @click="newTaskOpen = true">
            {{ t('projectPage.newTask') }}
          </GnButton>
        </template>
      </GnPageHeader>

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

      <!-- Заметка (Markdown: ссылки, контекст) -->
      <GnCard v-if="project.note.trim()" class="note-card">
        <template #title>{{ t('projectPage.note') }}</template>
        <div class="md-view" v-html="noteHtml" />
      </GnCard>

      <!-- Прогресс: выполнено задач проекта -->
      <GnProgress
        class="progress"
        :value="doneCount"
        :max="Math.max(tasks.length, 1)"
        :label="t('projectPage.progress', { done: doneCount, total: tasks.length })"
      />

      <!-- Список задач проекта -->
      <GnToolbar class="filters">
        <GnSelect
          v-model="filters.status"
          class="filter-select"
          :options="statusOptionsWithAny"
        />
        <GnSelect
          v-model="filters.tag_id"
          class="filter-select"
          icon="ph-tag"
          :options="[
            { value: '', label: t('common.any') },
            ...tags.map((tg) => ({ value: String(tg.id), label: tg.name })),
          ]"
        />
      </GnToolbar>

      <GnEmptyState
        v-if="filteredTasks.length === 0"
        icon="ph-list-magnifying-glass"
        :title="t('projectPage.emptyTitle')"
        :text="t('projectPage.emptyText')"
      />

      <div class="task-list">
        <TaskCard
          v-for="task in filteredTasks"
          :key="task.id"
          :task="task"
          @status-change="setStatus"
          @delete="askDelete"
        />
      </div>

      <!-- Дерево подзадач проекта (наглядное, с прогрессом) -->
      <div class="tree-section">
        <h3 class="tree-title">{{ t('projectPage.tree') }}</h3>
        <TaskTree
          :tasks="tasks"
          @status-change="setStatus"
          @add-subtask="newTaskOpen = true"
          @delete="askDelete"
        />
      </div>
    </template>

    <QuickPrompt v-model:open="newTaskOpen" :title="t('projectPage.promptTask')" @confirm="createTaskForProject" />

    <GnDrawer v-model:open="drawerOpen" :title="project?.name || t('projects.edit')" position="right" @close="closeDrawer">
      <form class="edit-form" @submit.prevent="saveEdit">
        <GnInput v-model="editForm.name" :label="t('projects.form.name')" required />
        <GnSelect
          v-model="editForm.relevance_status"
          :label="t('projects.form.relevance')"
          icon="ph-activity"
          :options="relevanceOptions"
        />
        <GnInput v-model.number="editForm.priority" :label="t('projects.form.priority')" type="number" />
        <GnTextarea v-model="editForm.note" :label="t('projects.form.note')" :rows="6" />
        <GnCheckbox v-model="showPreview" :label="t('projects.form.preview')" />
        <div v-if="showPreview" class="md-preview" v-html="notePreview" />
        <div class="form-actions">
          <GnButton type="submit" variant="accent" icon="ph-check">
            {{ t('common.save') }}
          </GnButton>
          <GnButton type="button" variant="secondary" @click="closeDrawer">
            {{ t('common.cancel') }}
          </GnButton>
        </div>
      </form>
    </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>
.page-skeleton {
  margin-bottom: 1rem;
}
.note-card {
  margin-bottom: 1.5rem;
}
.md-view {
  overflow-x: auto;
}
.progress {
  margin-bottom: 1.5rem;
}
.filters {
  margin-bottom: 1rem;
}
.filters :deep(.form-group) {
  width: auto;
  margin-bottom: 0;
}
.filters :deep(.select-wrap) {
  width: 14rem;
  margin-top: 0;
}
.task-list {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}
.tree-section {
  margin-top: 2rem;
}
.tree-title {
  margin: 0 0 0.75rem;
  font-size: 1.1rem;
}
.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);
  border-radius: 6px;
  padding: 0.75rem;
  overflow-x: auto;
}
</style>