Newer
Older
gnexus-tasks / frontend / src / views / JournalView.vue
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Project, type TaskEvent } from '../api'
import { useRealtimeRefresh } from '../realtime'
import { formatDateTime, statusLabel } from '../taskui'
import Pagination from '../components/Pagination.vue'

// Журнал действий (ТЗ 3.20): кто и что делал с задачами. Уникален тем, что
// показывает актора — у остальных разделов есть только результат.
// Единственный серверно-пагинированный список: журнал растёт быстро, целиком
// его не отдают (потолок per_page на бэке — 100).

const { t } = useI18n()

const PER_PAGE = 50

const events = ref<TaskEvent[]>([])
const projects = ref<Project[]>([])
const total = ref(0)
const page = ref(1)
const loading = ref(false)
const error = ref('')

// Виды записей — из tasklog.py; порядок: как читается жизнь задачи
const KINDS = [
  'created',
  'eligible_changed',
  'claimed',
  'released',
  'status_changed',
  'completed',
  'accepted',
  'rejected',
  'deleted',
] as const

const filters = ref({
  kind: '',
  actor: '' as '' | 'user' | 'agent',
  project: '' as string | number,
})

const kindOptions = computed(() => [
  { value: '', label: t('journal.filterKindAll') },
  ...KINDS.map((k) => ({ value: k, label: t(`journal.kind.${k}`) })),
])

const actorOptions = computed(() => [
  { value: '', label: t('journal.filterActorAll') },
  { value: 'user', label: t('journal.actorUser') },
  { value: 'agent', label: t('journal.actorAgent') },
])

const projectOptions = computed(() => [
  { value: '', label: t('journal.filterProjectAll') },
  ...projects.value.map((p) => ({ value: String(p.id), label: p.name })),
])

// Имена проектов: запись журнала хранит только id (проект может быть удалён),
// поэтому в справочник идут и активные, и архивные — иначе у архивного проекта
// в журнале стояло бы «проект удалён». В фильтре — только активные.
const projectNames = ref(new Map<number, string>())

let loadSeq = 0

async function load() {
  const seq = ++loadSeq
  loading.value = true
  error.value = ''
  try {
    const res = await api.listTaskEvents({
      kind: filters.value.kind,
      actor_kind: filters.value.actor,
      project_id: filters.value.project === '' ? '' : Number(filters.value.project),
      page: page.value,
      per_page: PER_PAGE,
    })
    if (seq !== loadSeq) return
    events.value = res.items
    total.value = res.total
    // страница могла опустеть (фильтр сузился) — уходим на последнюю
    if (res.page !== page.value) page.value = res.page
  } catch (e) {
    if (seq !== loadSeq) return
    error.value = String(e)
  } finally {
    if (seq === loadSeq) loading.value = false
  }
}

// Смена фильтра — с первой страницы
watch(
  filters,
  () => {
    page.value = 1
    void load()
  },
  { deep: true },
)

watch(page, () => void load())

onMounted(async () => {
  await load()
  try {
    // Активные — для фильтра; архивные добавляем только в справочник имён
    const [active, archived] = await Promise.all([
      api.listProjects(false),
      api.listProjects(true),
    ])
    projects.value = active
    projectNames.value = new Map([...active, ...archived].map((p) => [p.id, p.name]))
  } catch (e) {
    error.value = String(e)
  }
})

// Живость: любое изменение задачи — новая запись в журнале
useRealtimeRefresh(load, ['task.changed', 'task.review', 'task.claimed', 'task.deleted'])

// Комментарий записи: у смены статуса и флага это машинный код
// («to_do→in_progress», «on»/«off») — интерфейс читает его своими словами,
// у остальных видов (закрытие, возврат, отказ) это обычный текст автора
function commentText(e: TaskEvent): string {
  const c = e.comment ?? ''
  if (!c) return ''
  if (e.kind === 'status_changed') {
    const [from, to] = c.split('→')
    return `${statusLabel(from ?? '')} → ${statusLabel(to ?? '')}`
  }
  if (e.kind === 'eligible_changed') {
    return c === 'on' ? t('journal.eligibleOn') : t('journal.eligibleOff')
  }
  return c
}

const columns = computed(() => [
  { key: 'created_at', label: t('journal.col.when') },
  { key: 'kind', label: t('journal.col.event') },
  { key: 'task_title', label: t('journal.col.task') },
  { key: 'project_id', label: t('journal.col.project') },
  { key: 'actor_name', label: t('journal.col.actor') },
  { key: 'comment', label: t('journal.col.comment') },
])

// «Откуда пришло» — значки вида канала: журнал нужен в том числе чтобы видеть,
// что действие пришло не из интерфейса
const VIA_ICONS: Record<string, string> = {
  ui: 'ph-monitor',
  api: 'ph-plug',
  mcp: 'ph-cpu',
}
</script>

<template>
  <section>
    <GnPageHeader kicker="GNexus Tasks" :title="t('journal.title')">
      <template #meta>{{ t('journal.count', { n: total }) }}</template>
      <template #actions>
        <div class="header-filters">
          <div class="filter-box">
            <GnSelect
              v-model="filters.kind"
              icon="ph-funnel"
              :options="kindOptions"
              :aria-label="t('journal.filterKind')"
            />
          </div>
          <div class="filter-box">
            <GnSelect
              v-model="filters.actor"
              icon="ph-user-circle"
              :options="actorOptions"
              :aria-label="t('journal.filterActor')"
            />
          </div>
          <div class="filter-box filter-project">
            <GnSelect
              v-model="filters.project"
              icon="ph-folder"
              :options="projectOptions"
              :aria-label="t('journal.filterProject')"
            />
          </div>
        </div>
        <GnLoader
          v-if="loading && events.length"
          circle
          label=""
          :aria-label="t('common.loading')"
          class="refresh-loader"
        />
      </template>
    </GnPageHeader>

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

    <div v-if="loading && !events.length" class="journal-skeleton" aria-hidden="true">
      <span v-for="i in 8" :key="i" class="skeleton skeleton-row" />
    </div>

    <GnTable
      v-else
      :columns="columns"
      :rows="events"
      :empty-text="t('journal.empty')"
      class="journal-table"
    >
      <template #cell-created_at="{ row }">
        <span class="when">{{ formatDateTime(row.created_at) }}</span>
      </template>

      <template #cell-kind="{ row }">
        <GnBadge :variant="row.kind === 'rejected' ? 'warning' : 'secondary'">
          {{ t(`journal.kind.${row.kind}`) }}
        </GnBadge>
      </template>

      <template #cell-task_title="{ row }">
        <RouterLink v-if="row.task_id" class="task-link" :to="`/tasks/${row.task_id}`">
          {{ row.task_title }}
        </RouterLink>
        <!-- задача удалена: запись осталась снапшотом заголовка -->
        <span v-else class="task-gone" :title="t('journal.taskDeleted')">{{ row.task_title }}</span>
      </template>

      <template #cell-project_id="{ row }">
        <span v-if="row.project_id" class="project">
          <i class="ph ph-folder" aria-hidden="true" />
          {{ projectNames.get(row.project_id) ?? t('journal.projectGone') }}
        </span>
      </template>

      <template #cell-actor_name="{ row }">
        <span class="actor">
          <i
            :class="`ph ${row.actor_kind === 'agent' ? 'ph-sparkle' : 'ph-user'}`"
            aria-hidden="true"
          />
          <span class="actor-name">{{ row.actor_name }}</span>
          <i
            :class="`ph ${VIA_ICONS[row.via] ?? 'ph-monitor'} via`"
            :title="t(`journal.via.${row.via}`)"
            aria-hidden="true"
          />
        </span>
      </template>

      <template #cell-comment="{ row }">
        <span class="comment">{{ commentText(row) }}</span>
      </template>
    </GnTable>

    <Pagination v-model:page="page" :total="total" :per-page="PER_PAGE" />
  </section>
</template>

<style scoped>
/* Фильтры в шапке: три поля одного вида. Подписи сняты — вариант в поле
   читается сам, а китовский запас под полем у form-group надо снять, иначе
   поля не сходятся по вертикали с соседями (приём из ListView) */
.header-filters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  align-items: center;
}
.filter-box {
  width: 190px;
}
.filter-project {
  min-width: 190px;
  width: auto;
}
.filter-box :deep(.form-group) {
  width: 100%;
  min-width: 0;
  margin-bottom: 0;
}
.filter-box :deep(.label) {
  margin-bottom: 0;
}
.journal-skeleton {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}
.journal-skeleton .skeleton-row {
  height: 42px;
  border-radius: 3px;
}
/* Таблица журнала: комментарий — самая длинная колонка, ему и сжиматься */
.journal-table :deep(td) {
  vertical-align: top;
}
.when {
  white-space: nowrap;
  font-size: 0.9em;
  color: var(--text-muted, #888);
  font-variant-numeric: tabular-nums;
}
.task-link {
  color: inherit;
}
.task-gone {
  color: var(--text-muted, #888);
}
.actor {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  white-space: nowrap;
}
.actor-name {
  overflow-wrap: anywhere;
}
.actor .via {
  color: var(--text-muted, #888);
  font-size: 0.9em;
}
.project {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-size: 0.9em;
  color: var(--text-muted, #888);
}
.comment {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  font-size: 0.9em;
}
</style>