Newer
Older
gnexus-tasks / frontend / src / views / ProjectView.vue
<script setup lang="ts">
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToast } from 'gnexus-ui-kit/vue'
import { celebrateEarned } from '../gamification'
import { api, ApiError, type Project, type Tag, type Task } from '../api'
import { deadlineOverdue, gradeToPriority, priorityOptions, priorityToGrade, priorityLabel, priorityVariant, relevanceLabel, renderMarkdown, setPageTitle, statusOptions } from '../taskui'
import MdEditor from '../components/MdEditor.vue'
import InlineField from '../components/InlineField.vue'
import TaskForm from '../components/TaskForm.vue'
import TaskCard from '../components/TaskCard.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)

// Тайтл вкладки — название проекта (после загрузки уточняет meta маршрута)
watchEffect(() => {
  setPageTitle(project.value?.name ?? t('nav.projects'))
})

// Фильтры списка: статус, тег
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 stats = computed(() => {
  const list = tasks.value
  const open = list.filter((task) => task.status !== 'done' && task.status !== 'cancelled')
  return {
    total: list.length,
    done: list.filter((task) => task.status === 'done').length,
    inProgress: list.filter((task) => task.status === 'in_progress').length,
    toDo: list.filter((task) => task.status === 'to_do').length,
    overdue: open.filter((task) => deadlineOverdue(task)).length,
  }
})

const doneCount = computed(() => stats.value.done)

// Сайдбар «Статистика» — строка = иконка + термин + значение
const statRows = computed(() => {
  const s = stats.value
  const rows: { key: string; icon: string; term: string; value: string; warn?: boolean }[] = [
    { key: 'total', icon: 'ph-stack', term: t('projectPage.total'), value: String(s.total) },
    { key: 'done', icon: 'ph-check-circle', term: t('projectPage.doneN'), value: String(s.done) },
    { key: 'inProgress', icon: 'ph-circle-half', term: t('projectPage.inProgress'), value: String(s.inProgress) },
    { key: 'toDo', icon: 'ph-square', term: t('projectPage.toDo'), value: String(s.toDo) },
  ]
  if (s.overdue)
    rows.push({ key: 'overdue', icon: 'ph-alarm', term: t('projectPage.overdue'), value: String(s.overdue), warn: true })
  return rows
})

// Сайдбар «Параметры» проекта — обе строки всегда (пустой приоритет — «не указано»,
// кликабельно); обе строки инлайн-редактируемые
const paramRows = computed(() => {
  const project0 = project.value
  if (!project0) return []
  return [
    {
      key: 'relevance',
      icon: 'ph-activity',
      term: t('projects.form.relevance'),
      value: relevanceLabel(project0.relevance_status),
      badge: undefined,
      empty: false,
      inline: true,
    },
    {
      key: 'priority',
      icon: 'ph-flag',
      term: t('projects.form.priority'),
      value: project0.priority === null ? '' : priorityLabel(project0.priority),
      badge: project0.priority === null ? undefined : priorityVariant(project0.priority),
      empty: project0.priority === null,
      inline: true,
    },
  ]
})

// Теги, используемые в задачах проекта, с количеством
const tagRows = computed(() => {
  const counts = new Map<string, number>()
  tasks.value.forEach((task) => task.tags.forEach((tag) => counts.set(tag.name, (counts.get(tag.name) ?? 0) + 1)))
  return [...counts.entries()].sort((a, b) => b[1] - a[1])
})

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)

// /projects/:id → /projects/:id переиспользует инстанс компонента —
// перезагружаемся по смене id (та же логика, что в TaskView)
watch(projectId, () => {
  void load()
})

// ---------- Инлайн-редактирование полей проекта ----------
// Draft сеется на @open, ✓ шлёт PATCH; ответ заменяет project.value —
// шапка, бейджи и заметка пересчитаются сами.

const draftName = ref('')
const draftNote = ref('')
const noteEditing = ref(false)
const noteBusy = ref(false)
const draftRelevance = ref('')
const draftPriority = ref('')

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

// Инлайн-PATCH: ошибка → тост + throw (InlineField оставит редактор открытым)
async function patchProject(patch: Partial<Project>) {
  const project0 = project.value
  if (!project0) return
  try {
    project.value = await api.updateProject(project0.id, patch)
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
    throw e
  }
}

async function saveName() {
  const name = draftName.value.trim()
  if (!name) throw new Error('empty') // пустое имя не сохраняем
  await patchProject({ name })
}

async function saveNote() {
  noteBusy.value = true
  try {
    await patchProject({ note: draftNote.value })
    noteEditing.value = false
  } catch {
    // тост уже показан patchProject
  } finally {
    noteBusy.value = false
  }
}

async function saveRelevance() {
  await patchProject({ relevance_status: draftRelevance.value })
}

async function savePriority() {
  await patchProject({
    priority: draftPriority.value === '' ? null : gradeToPriority(draftPriority.value),
  })
}

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

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


function startEdit() {
  if (!project.value) return
  editForm.value = {
    name: project.value.name,
    relevance_status: project.value.relevance_status,
    priorityGrade: project.value.priority === null ? '' : priorityToGrade(project.value.priority),
    note: project.value.note,
  }
  drawerOpen.value = true
}

function closeDrawer() {
  drawerOpen.value = false
}

async function saveEdit() {
  if (!project.value) return
  try {
    await api.updateProject(project.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) })
  }
}

async function setStatus(_task: Task, status: string) {
  error.value = ''
  try {
    await api.updateTask(_task.id, { status })
    celebrateEarned()
    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) })
  }
}

// Новая задача проекта: полная форма (TaskForm create + project_id по умолчанию)
const newTaskOpen = ref(false)

// После создания ведём пользователя на страницу задачи — детали заполняются там
function onTaskCreated(task: Task) {
  newTaskOpen.value = false
  void load()
  void router.push(`/tasks/${task.id}`)
}

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="GNexus Tasks" :title="project.name">
        <template #title>
          <!-- Инлайн-правка названия: клик по нему же → поле на его месте -->
          <InlineField :label="t('task.editTitle')" :save="saveName" @open="draftName = project.name">
            <template #display="{ edit }">
              <button class="title-editable" type="button" @click="edit">{{ project.name }}</button>
            </template>
            <template #edit="{ save }">
              <GnInput
                v-model="draftName"
                autofocus
                class="title-input"
                @keydown.enter="save"
              />
            </template>
          </InlineField>
        </template>
        <template #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: tasks.length }) }}
          </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>

      <div class="project-grid">
        <div class="project-main">
          <!-- Заметка (Markdown: ссылки, контекст): карандаш — правка прямо здесь -->
          <GnCard class="note-card">
            <template #title>
              <span class="card-title-icon"><i class="ph ph-notebook" aria-hidden="true" /></span>
              {{ t('projectPage.note') }}
              <GnIconButton
                v-if="!noteEditing"
                icon="ph-pencil-simple"
                size="sm"
                :label="t('projectPage.editNote')"
                @click="noteEditing = true; draftNote = project.note"
              />
            </template>
            <div v-if="noteEditing" class="inline-note">
              <MdEditor v-model="draftNote" caption="" :rows="8" />
              <span class="inline-actions">
                <GnIconButton
                  icon="ph-check"
                  size="sm"
                  :label="t('common.save')"
                  :disabled="noteBusy"
                  @click="saveNote"
                />
                <GnIconButton
                  icon="ph-x"
                  size="sm"
                  :label="t('common.cancel')"
                  :disabled="noteBusy"
                  @click="noteEditing = false"
                />
              </span>
            </div>
            <template v-else>
              <div v-if="project.note.trim()" class="md-view" v-html="noteHtml" />
              <p v-else class="muted">
                <i class="ph ph-note-blank" aria-hidden="true" />
                {{ t('projectPage.noteEmpty') }}
              </p>
            </template>
          </GnCard>

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

          <!-- Фильтры списка -->
          <div class="filters">
            <GnSelect
              v-model="filters.status"
              :label="t('list.filterStatus')"
              icon="ph-funnel"
              :options="statusOptionsWithAny"
            />
            <GnSelect
              v-model="filters.tag_id"
              :label="t('list.filterTag')"
              icon="ph-tag"
              :options="[
                { value: '', label: t('common.any') },
                ...tags.map((tg) => ({ value: String(tg.id), label: tg.name })),
              ]"
            />
            <GnIconButton
              v-if="filters.status || filters.tag_id"
              class="filters-reset"
              icon="ph-x"
              :label="t('common.cancel')"
              @click="filters.status = ''; filters.tag_id = ''"
            />
          </div>

          <GnEmptyState
            v-if="filteredTasks.length === 0"
            icon="ph-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"
              :show-project="false"
              @status-change="setStatus"
              @delete="askDelete"
            />
          </div>
        </div>

        <!-- Сайдбар: статистика, параметры, теги -->
        <aside class="project-side">
          <GnCard class="side-card">
            <template #title>
              <span class="card-title-icon"><i class="ph ph-chart-bar" aria-hidden="true" /></span>
              {{ t('projectPage.stats') }}
            </template>
            <div class="p-rows">
              <div v-for="row in statRows" :key="row.key" class="p-row" :class="{ warn: row.warn }">
                <i :class="`ph ${row.icon}`" aria-hidden="true" />
                <span class="p-term">{{ row.term }}</span>
                <span class="p-value">{{ row.value }}</span>
              </div>
            </div>
          </GnCard>

          <GnCard class="side-card">
            <template #title>
              <span class="card-title-icon"><i class="ph ph-list-dashes" aria-hidden="true" /></span>
              {{ t('task.fields') }}
            </template>
            <div class="p-rows">
              <div
                v-for="row in paramRows"
                :key="row.key"
                class="p-row editing"
              >
                <i :class="`ph ${row.icon}`" aria-hidden="true" />
                <span class="p-term">{{ row.term }}</span>

                <!-- Актуальность: select -->
                <InlineField
                  v-if="row.key === 'relevance'"
                  :label="t('projects.form.relevance')"
                  :save="saveRelevance"
                  @open="draftRelevance = project.relevance_status"
                >
                  <template #display="{ edit }">
                    <span class="p-value clickable" @click="edit">{{ row.value }}</span>
                  </template>
                  <template #edit="{ save }">
                    <GnSelect
                      v-model="draftRelevance"
                      :options="relevanceOptions"
                      class="inline-select"
                      @change="save"
                    />
                  </template>
                </InlineField>

                <!-- Приоритет: select -->
                <InlineField
                  v-else
                  :label="t('projects.form.priority')"
                  :save="savePriority"
                  @open="draftPriority = project.priority === null ? '' : priorityToGrade(project.priority)"
                >
                  <template #display="{ edit }">
                    <GnBadge
                      v-if="row.badge"
                      :variant="row.badge"
                      class="p-value-badge"
                      @click="edit"
                    >
                      {{ row.value }}
                    </GnBadge>
                    <span v-else class="p-value empty clickable" @click="edit">
                      {{ t('common.notSet') }}
                    </span>
                  </template>
                  <template #edit="{ save }">
                    <GnSelect
                      v-model="draftPriority"
                      :options="priorityOptionsWithNone"
                      class="inline-select"
                      @change="save"
                    />
                  </template>
                </InlineField>
              </div>
            </div>
          </GnCard>

          <GnCard v-if="tagRows.length" class="side-card">
            <template #title>
              <span class="card-title-icon"><i class="ph ph-tag" aria-hidden="true" /></span>
              {{ t('stack.form.tags') }}
            </template>
            <div class="p-rows">
              <div v-for="[name, count] in tagRows" :key="name" class="p-row">
                <i class="ph ph-tag" aria-hidden="true" />
                <span class="p-value">{{ name }}</span>
                <GnBadge variant="neutral">{{ count }}</GnBadge>
              </div>
            </div>
          </GnCard>
        </aside>
      </div>
    </template>

    <GnDrawer v-model:open="newTaskOpen" :title="t('projectPage.newTask')" position="right">
      <TaskForm
        v-if="newTaskOpen"
        :key="projectId"
        :project-id="projectId"
        @saved="onTaskCreated"
        @cancel="newTaskOpen = false"
      />
    </GnDrawer>

    <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"
        />
        <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="success" icon="ph-check-circle">
            {{ t('common.save') }}
          </GnButton>
          <GnButton type="button" variant="secondary" class="btn-cancel" @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;
}
/* Две колонки на широком экране: содержимое + сайдбар */
.project-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 340px;
  gap: 1.5rem;
  align-items: start;
}
@media (max-width: 960px) {
  .project-grid {
    grid-template-columns: 1fr;
  }
}
.project-main > * + *,
.project-side > * + * {
  margin-top: 1.5rem;
}
.md-view {
  overflow-x: auto;
  font-size: 1.02rem;
  line-height: 1.65;
}
.p-rows {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
}
.p-row {
  display: flex;
  gap: 0.6rem;
  align-items: baseline;
  font-size: 0.95rem;
}
.p-row > i {
  flex: 0 0 1.25rem;
  text-align: center;
  color: var(--accent, #7aa2f7);
  font-size: 1.05em;
  align-self: center;
}
.p-term {
  color: var(--text-muted, #888);
  flex: 1 1 auto;
}
.p-value {
  flex: 0 0 auto;
  font-weight: 600;
  overflow-wrap: anywhere;
}
.p-row.warn > i,
.p-row.warn .p-value {
  color: var(--danger, #f7768e);
}
/* строки с инлайн-правкой: редактору нужен center, а не baseline */
.p-row.editing {
  align-items: center;
}
/* кликабельное значение параметра: курсор + подсветка по hover */
.p-value.clickable {
  cursor: pointer;
}
.p-value.clickable:hover {
  color: var(--accent, #7aa2f7);
}
.p-value.empty {
  color: var(--text-muted, #888);
  font-weight: normal;
}
.p-value-badge {
  cursor: pointer;
}
/* инлайн-редакторы: обёртка .form-group кита в строке — без отступов формы, ужато */
.p-row .form-group {
  margin: 0;
  min-width: 0;
}
.p-row :deep(.form-group .label) {
  margin: 0;
}
.p-row :deep(.form-group .label .input) {
  margin-top: 0;
  min-height: 0;
  padding: 4px 10px;
  border-bottom-width: 2px;
  font-size: 14px;
}
.p-row :deep(.inline-select) {
  min-width: 7rem;
}
/* стрелка/иконочный отступ кита в инлайне не нужен */
.p-row :deep(.select-wrap .select) {
  padding-left: 10px;
}
/* название проекта как кнопка правки */
.title-editable {
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  color: inherit;
  text-align: inherit;
  cursor: pointer;
  overflow-wrap: anywhere;
}
.title-editable:hover {
  color: var(--accent, #7aa2f7);
}
/* правка названия: поле кита (input.input) наследует h1, компактнее по высоте */
/* правка названия: поле кита (GnInput, обёртка .form-group) наследует h1 */
.page-header :deep(.inline-editing) {
  width: 100%;
  max-width: 34rem;
  flex-wrap: nowrap; /* редактор и кнопки в одной строке шапки */
}
.page-header .form-group {
  margin: 0;
  flex: 1 1 auto;
  min-width: 0;
}
.page-header :deep(.form-group .label) {
  margin: 0;
}
.page-header :deep(input.title-input) {
  font: inherit;
  font-weight: inherit;
  min-height: 0;
  padding: 4px 12px;
  margin-top: 0;
}
/* правка заметки: редактор + кнопки */
.inline-note {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.inline-note .inline-actions {
  display: flex;
  gap: 0.25rem;
}
.muted {
  color: var(--text-muted, #888);
  font-size: 0.9em;
}
/* фильтры: подписанные селекты в ряд, кнопка сброса прижата к низу */
.filters {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  align-items: flex-end;
  margin-bottom: 1rem;
}
.filters :deep(.form-group) {
  width: 14rem;
  margin-bottom: 0;
}
.filters :deep(.select-wrap) {
  margin-top: 0;
}
.filters-reset {
  margin-bottom: 0.25rem;
}
.task-list {
  display: flex;
  flex-direction: column;
  gap: 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;
}
/* Отмена — без рамки, менее заметная, чем вторичная */
.btn-cancel {
  border-color: transparent;
  color: var(--text-muted, #888);
}
.btn-cancel:hover {
  border-color: transparent;
  color: var(--accent, #7aa2f7);
}
</style>