Newer
Older
gnexus-tasks / frontend / src / views / JournalView.vue
<script setup lang="ts">
import { computed, h, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type TaskEvent } from '../api'
import { useRealtimeRefresh } from '../realtime'
import { formatDate, formatTime, 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 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

// Фильтра по проекту здесь нет (0.94): проектов у владельца будут сотни, и
// выпадающий список из них искать нужный не помогает — разрез по проектам
// живёт в самом разделе «Проекты» (та же логика, что в общем списке задач, 0.73)
const filters = ref({
  kind: '',
  actor: '' as '' | 'user' | 'agent',
})

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') },
])

// Имена проектов: запись журнала хранит только 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,
      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 {
    // Архивные проекты нужны только в справочнике имён: запись журнала хранит
    // один id, а проект к моменту чтения мог уйти в архив
    const [active, archived] = await Promise.all([
      api.listProjects(false),
      api.listProjects(true),
    ])
    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
}

// Комментарий показываем иконкой и только когда он есть (0.94): записи журнала
// в основном без комментария, и колонка из пустых ячеек съедала половину ширины
// таблицы. Полный текст открывается модалкой — в ячейке он не влезал и рвал строку
const commentRow = ref<TaskEvent | null>(null)
const commentOpen = computed({
  get: () => commentRow.value !== null,
  set: (open: boolean) => {
    if (!open) commentRow.value = null
  },
})

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') },
  // Заголовок колонки комментария — иконкой (0.94): слово «Комментарий» шире
  // самой колонки, в которой теперь стоит кнопка. У GnTable нет слота заголовка,
  // но она принимает в label готовый узел — иконке даём доступное имя.
  // Обход описан в доке кита: gnexus-ui-kit/docs/tasks/gn-table-header-slot.md
  // (там же просьба о слоте header-* и ручках колонок)
  {
    key: 'comment',
    label: h('i', {
      class: 'ph ph-chat-text',
      role: 'img',
      'aria-label': t('journal.col.comment'),
      title: 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>
        <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 }">
        <!-- Дата и время — отдельными частями (0.94): на телефоне колонка узкая,
             и они встают в две строки, а не растягивают таблицу -->
        <span class="when">
          <span>{{ formatDate(row.created_at) }}</span>
          <span class="when-sep" aria-hidden="true">, </span>
          <span>{{ formatTime(row.created_at) }}</span>
        </span>
      </template>

      <template #cell-kind="{ row }">
        <span class="event">
          <GnBadge :variant="row.kind === 'rejected' ? 'warning' : 'secondary'">
            {{ t(`journal.kind.${row.kind}`) }}
          </GnBadge>
          <!-- Дубль актора под бейджем — только для телефона (0.94): там его
               колонка скрыта, а ради актора журнал и существует -->
          <span class="actor actor-inline">
            <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>
        </span>
      </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 }">
        <!-- Комментарий — иконкой и только когда он есть (0.94): пустых ячеек
             в журнале большинство, кнопка на каждую читалась бы шумом -->
        <GnIconButton
          v-if="commentText(row)"
          icon="ph-chat-text"
          size="sm"
          :label="t('journal.showComment')"
          @click="commentRow = row"
        />
      </template>
    </GnTable>

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

    <!-- Комментарий целиком: в ячейке он не влезал, а обрезать текст автора нельзя -->
    <GnModal
      :open="commentOpen"
      :title="t('journal.col.comment')"
      @update:open="commentOpen = $event"
    >
      <div v-if="commentRow" class="comment-modal">
        <!-- Контекст записи: диалог открывается из строки таблицы, под ним
             остаётся весь журнал — без этих слов непонятно, чей это комментарий -->
        <p class="comment-context">
          {{ t(`journal.kind.${commentRow.kind}`) }}
          <template v-if="commentRow.task_title"> · {{ commentRow.task_title }}</template>
        </p>
        <p class="comment-full">{{ commentText(commentRow) }}</p>
      </div>
      <template #actions>
        <GnButton @click="commentOpen = false">{{ t('common.close') }}</GnButton>
      </template>
    </GnModal>
  </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-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 {
  display: inline-flex;
  align-items: baseline;
  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-inline {
  display: none;
  font-size: 0.85em;
  color: var(--text-muted, #888);
}
.event {
  display: inline-flex;
  flex-direction: column;
  align-items: flex-start;
}
.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-full {
  margin: 0;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
.comment-context {
  margin: 0 0 0.75rem;
  color: var(--gn-color-text-muted, #787c99);
  font-size: 0.9em;
  overflow-wrap: anywhere;
}

/* Мобильные (0.94): шесть колонок в 390px не влезают даже с иконкой вместо
   комментария. Сжимаем отступы ячеек, дату ставим в две строки, убираем две
   справочные колонки и разрешаем переносы — таблица остаётся таблицей, но
   перестаёт ехать по горизонтали */
@media (max-width: 767px) {
  .journal-table :deep(.table-row td),
  .journal-table :deep(.table-row th) {
    padding: 8px 8px;
  }
  /* Бейдж события — самая широкая ячейка на телефоне: моноширинный капс
     13px рвёт «ДОСТУПНОСТЬ» на две строки и задаёт таблице нижнюю границу
     ширины. На телефоне он мельче и плотнее */
  .journal-table :deep(.table-row .badge) {
    font-size: 0.75rem;
    padding: 4px 6px;
  }
  /* Колонка комментария — одна кнопка, отступы ячейки ей ни к чему */
  .journal-table :deep(.table-row > :nth-child(6)) {
    padding-left: 4px;
    padding-right: 4px;
  }
  /* Проект уходит: его имя — самая длинная строка в таблице, а задача уже
     связана со своим проектом ссылкой на странице задачи */
  .journal-table :deep(.table-row > :nth-child(4)) {
    display: none;
  }
  /* Актор уходит своей колонкой, но возвращается строкой под бейджем события:
     он узкий и в колонке оставлял половину ширины пустой.
     Колонки прячутся по номеру, потому что у columns кита нет ни class, ни
     hidden — обход и просьба об этом: gnexus-ui-kit/docs/tasks/gn-table-header-slot.md */
  .journal-table :deep(.table-row > :nth-child(5)) {
    display: none;
  }
  .actor-inline {
    display: inline-flex;
    margin-top: 0.25rem;
  }
  .when {
    flex-direction: column;
  }
  .when-sep {
    display: none;
  }
  .actor {
    white-space: normal;
  }
}
</style>