<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>