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 { useToast } from 'gnexus-ui-kit/vue'
import { api, type Project, type Task } from '../api'
import {
  gradeToPriority,
  priorityOptions,
  priorityToGrade,
  priorityLabel,
  priorityVariant,
  relevanceLabel,
  renderMarkdown,
} from '../taskui'
import QuickPrompt from '../components/QuickPrompt.vue'
import MdEditor from '../components/MdEditor.vue'

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

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

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
})

const editing = ref<Project | null>(null)
const drawerOpen = ref(false)
const editForm = ref({
  name: '',
  relevance_status: 'active',
  priorityGrade: '' as string,
  note: '',
})

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)


// Редактирование — в боковой панели, карточка остаётся компактной
function startEdit(project: Project) {
  editing.value = project
  editForm.value = {
    name: project.name,
    relevance_status: project.relevance_status,
    priorityGrade: project.priority === null ? '' : priorityToGrade(project.priority),
    note: project.note,
  }
  drawerOpen.value = true
}

function closeDrawer() {
  drawerOpen.value = false
  editing.value = null
}

async function saveEdit() {
  if (!editing.value) return
  try {
    await api.updateProject(editing.value.id, {
      name: editForm.value.name,
      relevance_status: editForm.value.relevance_status,
      priority:
        editForm.value.priorityGrade === '' ? null : gradeToPriority(editForm.value.priorityGrade),
      note: editForm.value.note,
    })
    toast.success({ title: t('common.saved') })
    closeDrawer()
    await load()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

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

async function createProject(name: string) {
  try {
    await api.createProject(name)
    toast.success({ title: t('common.created') })
    await load()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

// Удаление — через подтверждение
const deleteTarget = ref<Project | null>(null)
const confirmOpen = ref(false)

function askDelete(project: Project) {
  deleteTarget.value = project
  confirmOpen.value = true
}

async function remove() {
  const project = deleteTarget.value
  if (!project) return
  confirmOpen.value = false
  try {
    await api.deleteProject(project.id)
    toast.success({ title: t('common.projectDeleted') })
    await load()
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

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

const priorityOptionsWithNone = computed(() => [
  { value: '', label: t('common.none') },
  ...priorityOptions(),
])
</script>

<template>
  <section>
    <GnPageHeader kicker="gntodo" :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 projects" :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>
          <GnBadge v-if="project.priority !== null" :variant="priorityVariant(project.priority)">
            {{ priorityLabel(project.priority) }}
          </GnBadge>
          <GnBadge variant="neutral"><i class="ph ph-list-checks" aria-hidden="true" />
            {{ t('projectPage.taskCount', { n: taskCounts.get(project.id)?.total ?? 0 }) }}
          </GnBadge>
        </div>
        <div v-if="project.note" class="md-preview note" v-html="renderMarkdown(project.note)" />
        <div class="form-actions">
          <GnButton variant="secondary" size="sm" icon="ph-pencil-simple" @click="startEdit(project)">
            {{ t('projects.edit') }}
          </GnButton>
          <GnIconButton icon="ph-trash" :label="t('projects.delete')" @click="askDelete(project)" />
        </div>
      </GnCard>
    </div>

    <GnDrawer v-model:open="drawerOpen" :title="editing?.name || t('projects.edit')" position="right" @close="closeDrawer">
      <form v-if="editing" 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"
        />
        <GnSelect
          v-model="editForm.priorityGrade"
          :label="t('projects.form.priority')"
          icon="ph-flag"
          :options="priorityOptionsWithNone"
        />
        <MdEditor v-model="editForm.note" :caption="t('projects.form.note')" :rows="6" />
        <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>

    <QuickPrompt v-model:open="createOpen" :title="t('projects.prompt')" @confirm="createProject" />

    <GnConfirmDialog
      v-model:open="confirmOpen"
      :title="t('common.confirmTitle')"
      :message="t('common.deleteConfirmProject', { name: deleteTarget?.name ?? '' })"
      :confirm-text="t('common.delete')"
      :cancel-text="t('common.cancel')"
      confirm-variant="danger"
      @confirm="remove"
    />
  </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);
  border-radius: 6px;
  padding: 0.75rem;
  overflow-x: auto;
}
.note {
  margin-bottom: 0.75rem;
}
</style>