Newer
Older
gnexus-tasks / frontend / src / views / OptionsView.vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Task } from '../api'
import TaskCard from '../components/TaskCard.vue'

// «Выдача задачи» (ТЗ 3.8): под доступное время показываем те же карточки,
// что в списке (TaskCard). Взять в работу можно здесь через меню или на
// странице задачи — переход из выдачи несёт ?via=options, закрытие оттуда
// помечается «выбрал и сделал».

const { t } = useI18n()

// время в часах (можно 0.5); в API уходит в минутах
const availableHours = ref(3)
const options = ref<Task[]>([])
const error = ref('')
const loading = ref(false)

async function fetchOptions() {
  loading.value = true
  error.value = ''
  try {
    options.value = await api.suggestTasks(Math.round(availableHours.value * 60))
  } catch (e) {
    error.value = String(e)
  } finally {
    loading.value = false
  }
}

onMounted(fetchOptions)

async function setStatus(task: Task, status: string) {
  error.value = ''
  try {
    await api.updateTask(task.id, { status })
    await fetchOptions()
  } 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 target = deleteTarget.value
  if (!target) return
  confirmOpen.value = false
  error.value = ''
  try {
    await api.deleteTask(target.id)
    await fetchOptions()
  } catch (e) {
    error.value = String(e)
  }
}
</script>

<template>
  <section>
    <GnPageHeader kicker="GNexus Tasks" :title="t('options.title')">
      <!-- Спиннер обновления: данные уже есть, выдача дотягивается -->
      <template #actions>
        <GnLoader
          v-if="loading && options.length"
          circle
          label=""
          :aria-label="t('common.loading')"
          class="refresh-loader"
        />
      </template>
    </GnPageHeader>

    <!-- Доступное время в часах (0.5 = полчаса) + предложение, в одну строку -->
    <form class="time-form" @submit.prevent="fetchOptions">
      <GnInput
        v-model.number="availableHours"
        :label="t('options.availableHours')"
        type="number"
        step="0.5"
        min="0.5"
        max="24"
      />
      <GnButton type="submit" variant="primary" icon="ph-shuffle" :disabled="loading">
        {{ t('options.suggest') }}
      </GnButton>
    </form>

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

    <!-- Скелетон карточек выдачи: заголовок + ряд бейджей, как в TaskCard -->
    <div v-if="loading" class="list-skeleton" aria-hidden="true">
      <div v-for="i in 3" :key="i" class="card">
        <div class="card-content">
          <span class="skeleton skeleton-title" />
          <div class="sk-badges">
            <span v-for="j in 3" :key="j" class="skeleton skeleton-square" />
          </div>
        </div>
      </div>
    </div>

    <GnEmptyState
      v-else-if="options.length === 0"
      icon="ph-confetti"
      :title="t('options.emptyTitle')"
      :text="t('options.emptyText')"
    />

    <!-- Те же карточки, что в списке задач; открытие несёт ?via=options -->
    <div class="task-list">
      <TaskCard
        v-for="task in options"
        :key="task.id"
        :task="task"
        show-project
        :link-query="{ via: 'options' }"
        @status-change="setStatus"
        @delete="askDelete"
        @moved="fetchOptions"
      />
    </div>

    <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>
.time-form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 1rem;
  margin-bottom: 1.5rem;
}
/* GnInput кита рендерит div.form-group > label > (текст + иконка + input).
   Разворачиваем подпись и поле в один ряд: «Доступное время, часов ⏳ [3]»,
   кнопка «Предложить» — следом, вся полоса одной строкой */
.time-form :deep(.form-group) {
  flex: 0 0 auto;
  width: max-content;
  margin-bottom: 0;
}
.time-form :deep(.form-group .label) {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 0.5rem;
  white-space: nowrap;
}
.time-form :deep(input) {
  width: 6rem;
}
/* Выдача — по три карточки в ряд (0.95; просили «давай 3 в ряд»). Больше трёх
   тут и не бывает: бэкенд отдаёт максимум три задачи (pick_options, limit=3),
   так что весь ответ ложится одной строкой. Сетка — как у стека входящих (3.7):
   карточка тут та же (TaskCard), и ряд читается одинаково на обеих страницах;
   ломающиеся точки те же — 1080 и 720px. Скелетон повторяет сетку, чтобы
   загрузка не дёргала раскладку. */
.task-list,
.list-skeleton {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0.75rem;
}
.list-skeleton {
  margin-bottom: 1rem;
}
.task-list > .card,
.list-skeleton > .card {
  /* сетка ужимает карточку — длинные слова в заголовке переносятся,
     а не расширяют трек */
  min-width: 0;
}
/* Карточки ряда одной высоты: подвал «3 дня назад» встаёт на одну линию */
.task-list :deep(.task-card) {
  height: 100%;
}
@media (max-width: 1080px) {
  .task-list,
  .list-skeleton {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
@media (max-width: 720px) {
  .task-list,
  .list-skeleton {
    grid-template-columns: minmax(0, 1fr);
  }
}
/* карточка-скелет: как реальная карточка задачи — тянется на ширину */
.list-skeleton .card {
  width: 100%;
  max-width: none;
}
.list-skeleton .card-content > .skeleton-title {
  display: block;
  margin-bottom: 10px;
}
.sk-badges {
  display: flex;
  gap: 6px;
}
</style>