Newer
Older
gnexus-tasks / frontend / src / components / TaskMoveDialog.vue
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useToast } from 'gnexus-ui-kit/vue'
import { api, type Project, type Task } from '../api'

// Перенос задачи в другой проект (0.82).
//
// Почему API-вызов здесь, а не на странице, как у удаления: удаление — это вопрос
// «какой именно таск» и подтверждение, его задаёт список; перенос же вызревает из
// меню самой карточки и нужен всем четырём спискам сразу (задачи, проект, выдача,
// подзадачи). Держим вызов в диалоге, а наружу отдаём только событие moved —
// страница сама решает, как перечитать список (как после смены статуса).
//
// Перенос — обычный PATCH /api/tasks/{id} с project_id: бэкенд проверяет, что
// проект принадлежит пользователю, и рассылает task.changed. Статус не трогаем,
// поэтому XP/монеты за перенос не начисляются (они висят только на переходе
// в «завершено»).
const props = defineProps<{
  open: boolean
  task: Task
}>()

const emit = defineEmits<{
  'update:open': [value: boolean]
  /** Задача переехала — странице пора перечитать список */
  moved: []
}>()

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

const projects = ref<Project[]>([])
const target = ref('')
const busy = ref(false)

// Справочник тянем при каждом открытии: карточек задач на странице десятки, держать
// в каждой свой список проектов незачем — а открывают диалог редко, и проекты
// успевают появиться/уйти в архив
watch(
  () => props.open,
  async (open) => {
    if (!open) return
    // Текущий проект — исходное значение: чаще всего переносят «не в тот», а не
    // наугад, и открывать список заново с пустым полем было бы лишним шагом
    target.value = props.task.project ? String(props.task.project.id) : ''
    try {
      projects.value = await api.listProjects()
    } catch (e) {
      toast.error({ title: t('common.error'), text: String(e) })
    }
  },
  { immediate: true },
)

// Выбор проекта — тем же GnSelect кита, что в форме задачи (иконка папки, первым
// пунктом «без проекта»): это привычная в приложении форма вопроса «в каком проекте
// задача», а на телефоне — ещё и нативный список. Сорок с лишним проектов рядом
// радиокнопок читались бы простынёй.
const options = computed(() => {
  const current = props.task.project
  const list = projects.value.map((p) => ({ value: String(p.id), label: p.name }))
  // Задача может лежать в архивном проекте: listProjects отдаёт только активные,
  // и без этой строки селект открылся бы пустым, потеряв текущий выбор
  if (current && !list.some((o) => o.value === String(current.id))) {
    list.unshift({ value: String(current.id), label: current.name })
  }
  return [{ value: '', label: t('common.noProject') }, ...list]
})

async function move() {
  const targetId = target.value === '' ? null : Number(target.value)
  // Проект не менялся — просто закрываем, запроса не шлём
  if (targetId === (props.task.project?.id ?? null)) {
    emit('update:open', false)
    return
  }
  busy.value = true
  try {
    await api.updateTask(props.task.id, { project_id: targetId })
    const name = projects.value.find((p) => p.id === targetId)?.name
    toast.success({
      title: name ? t('task.moved', { project: name }) : t('task.movedOut'),
    })
    emit('moved')
    emit('update:open', false)
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  } finally {
    busy.value = false
  }
}
</script>

<template>
  <GnModal
    :open="props.open"
    :title="t('task.moveToProject')"
    @update:open="emit('update:open', $event)"
  >
    <div class="move-form">
      <!-- Какая именно задача переезжает: диалог открывается из карточки, но под
           ним остаётся весь список, и без этой строки легко перенести соседнюю -->
      <p class="move-task">{{ t('task.moveHint', { title: props.task.title }) }}</p>
      <GnSelect v-model="target" icon="ph-folder" :label="t('task.field.project')" :options="options" />
    </div>
    <template #actions>
      <!-- Акцент отдаём действию: у переноса нет своего смыслового варианта
           (в других диалогах приложения кнопка действия красится в danger или
           success, а «Отмена» берёт на себя primary), а «Переместить» без заливки
           читался бы как второстепенная кнопка, тогда как главная тут именно она.
           Китовый по умолчанию (без variant) — контурная, поэтому «Отмена» идёт без него -->
      <GnButton variant="primary" :loading="busy" icon="ph-arrows-left-right" @click="move">
        {{ t('common.move') }}
      </GnButton>
      <GnButton @click="emit('update:open', false)">{{ t('common.cancel') }}</GnButton>
    </template>
  </GnModal>
</template>

<style scoped>
.move-form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}
/* Заголовок задачи — мягким цветом и мельче: это контекст диалога, а не его
   содержимое. Многоточие в конце обрезает длинные названия, полное — в подсказке */
.move-task {
  margin: 0;
  color: var(--gn-color-text-medium, #a9b1d6);
  font-size: 0.9em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
</style>