Newer
Older
gnexus-tasks / frontend / src / components / TaskForm.vue
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Attachment, type Project, type Tag, type Task } from '../api'
import { notifyCreated } from '../gamification'
import { deadlinePeriodOptions, recurKindOptions, priorityOptions, gradeToPriority, priorityToGrade, weekdayShort } from '../taskui'
import { useToast } from 'gnexus-ui-kit/vue'
import MdEditor from './MdEditor.vue'
import DurationInput from './DurationInput.vue'

// Полная форма задачи: тип (радио), описание (Markdown-редактор с тулбаром),
// метаданные, дедлайн, рекуррентность, вложения (Ctrl+V — картинка),
// теги через GnTagInput (ввод нового имени создаёт тег), проект на лету.
// Сохранение делает сама (PATCH + опционально approve) и эмитит результат.

const props = defineProps<{
  /** Задача для правки; без неё — режим создания */
  task?: Task
  /** После сохранения утвердить задачу (флоу стека: правка = утверждение) */
  approveAfterSave?: boolean
  /** Надпись на кнопке подтверждения */
  submitLabel?: string
  /** Дефолты при создании: проект и родительская задача */
  projectId?: number
  parentId?: number
}>()

const emit = defineEmits<{
  saved: [task: Task]
  cancel: []
}>()

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

const projects = ref<Project[]>([])
const tags = ref<Tag[]>([])

const form = ref({
  title: '',
  task_type: 'one_time',
  description: '',
  project_id: '' as string | number,
  priorityGrade: '' as string,
  estimated_minutes: null as number | null,
  cost_estimate_money: null as number | null,
  deadline_date: '' as string,
  deadline_period: '' as string,
  recur_kind: '' as string,
  recur_interval_days: 1 as number | null,
  recurWeekday: {} as Record<string, boolean>,
  recur_day_of_month: 1 as number | null,
})

// Теги — имена (GnTagInput работает со строками); id резолвятся при сохранении
const selectedTags = ref<string[]>([])

// «Дополнительно» (оценки + дедлайн): при создании свёрнуто, при правке раскрыто
const extraItems = [{ id: 'extra', label: t('stack.form.extra'), icon: 'ph-plus-circle' }]
const extraOpen = ref(props.task ? 'extra' : '')

// Дни недели для правила повторения (ISO: пн=1..вс=7) — имена через Intl
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7].map((n) => ({
  value: String(n),
  label: weekdayShort(n),
}))

const attachments = ref<Attachment[]>([])
const uploading = ref(false)

onMounted(async () => {
  const task = props.task
  form.value = task
    ? {
        title: task.title,
        task_type: task.task_type ?? 'one_time',
        description: task.description,
        project_id: task.project?.id ?? '',
        priorityGrade: task.priority === null ? '' : priorityToGrade(task.priority),
        estimated_minutes: task.estimated_minutes,
        cost_estimate_money: task.cost_estimate_money,
        deadline_date: task.deadline_date ?? '',
        deadline_period: task.deadline_period ?? '',
        recur_kind: task.recur_kind ?? '',
        recur_interval_days: task.recur_interval_days ?? 1,
        recurWeekday: task.recur_weekdays
          ? Object.fromEntries(task.recur_weekdays.split(',').map((d) => [d, true]))
          : {},
        recur_day_of_month: task.recur_day_of_month ?? 1,
      }
    : {
        title: '',
        task_type: 'one_time',
        description: '',
        project_id: props.projectId ?? '',
        priorityGrade: '',
        estimated_minutes: null,
        cost_estimate_money: null,
        deadline_date: '',
        deadline_period: '',
        recur_kind: '',
        recur_interval_days: 1,
        recurWeekday: {},
        recur_day_of_month: 1,
      }
  selectedTags.value = task ? task.tags.map((t) => t.name) : []
  try {
    ;[projects.value, tags.value] = await Promise.all([api.listProjects(), api.listTags()])
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
  if (task && task.document_id !== null) void loadAttachments(task.document_id)
})

async function loadAttachments(documentId: number) {
  try {
    attachments.value = await api.listAttachments(documentId)
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

// Тег создаётся в каталоге один раз на имя: повторные вызовы (событие add +
// сохранение формы) дожиддаются той же операции, дубли POST не плодятся
const tagInFlight = new Map<string, Promise<Tag | null>>()

function ensureTag(name: string): Promise<Tag | null> {
  const existing = tags.value.find((tag) => tag.name === name)
  if (existing) return Promise.resolve(existing)
  let pending = tagInFlight.get(name)
  if (!pending) {
    pending = api
      .createTag(name)
      .then((tag) => {
        tags.value.push(tag)
        return tag
      })
      .catch((e) => {
        toast.error({ title: t('common.error'), text: String(e) })
        return null
      })
      .finally(() => tagInFlight.delete(name))
    tagInFlight.set(name, pending)
  }
  return pending
}

// Новый тег, введённый в поле, — сразу в каталог
async function onTagAdd(name: string) {
  await ensureTag(name)
}

const submitText = computed(() => props.submitLabel ?? t(props.task ? 'common.save' : 'common.create'))

const formEl = ref<HTMLFormElement | null>(null)
// busy: двойной клик по «Создать» не должен плодить две задачи
const busy = ref(false)

async function save() {
  if (busy.value) return
  busy.value = true
  try {
    // GnTagInput подтверждает набираемый тег только по Enter/вставке: если
    // текст остался в поле (клик по кнопке сразу после ввода), фиксируем его
    // синтетическим Enter — иначе при сохранении он молча теряется
    for (const el of Array.from(formEl.value?.querySelectorAll<HTMLInputElement>('.tag-input input') ?? [])) {
      if (el.value.trim()) {
        el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }))
      }
    }
    await nextTick()
    const tag_ids = (await Promise.all(selectedTags.value.map(ensureTag)))
      .filter((tag): tag is Tag => tag !== null)
      .map((tag) => tag.id)
    const f = form.value
    const patch = {
      title: f.title,
      description: f.description,
      project_id: f.project_id === '' ? null : Number(f.project_id),
      tag_ids,
      priority: f.priorityGrade === '' ? null : gradeToPriority(f.priorityGrade),
      estimated_minutes: f.estimated_minutes,
      cost_estimate_money: f.cost_estimate_money,
      deadline_date: f.deadline_date === '' ? null : f.deadline_date,
      deadline_period: f.deadline_period === '' ? null : f.deadline_period,
      task_type: f.task_type,
      recur_kind: f.task_type === 'recurring' && f.recur_kind ? f.recur_kind : null,
      recur_interval_days:
        f.task_type === 'recurring' && f.recur_kind === 'interval' ? f.recur_interval_days : null,
      recur_weekdays:
        f.task_type === 'recurring' && f.recur_kind === 'weekdays'
          ? Object.keys(f.recurWeekday)
              .filter((d) => f.recurWeekday[d])
              .sort()
              .join(',') || null
          : null,
      recur_day_of_month:
        f.task_type === 'recurring' && f.recur_kind === 'monthly' ? f.recur_day_of_month : null,
    }
    let task: Task
    if (props.task) {
      task = await api.updateTask(props.task.id, patch)
      if (props.approveAfterSave) await api.approveTask(props.task.id)
      toast.success({ title: props.approveAfterSave ? t('common.taskApproved') : t('common.saved') })
    } else {
      // Создание: TaskCreate принимает только заголовок/описание/родителя,
      // остальное доуточняется PATCH'ем
      const created = await api.createTask(f.title, f.description, props.parentId)
      task = await api.updateTask(created.id, patch)
      toast.success({ title: t('common.created'), text: t('task.createdRaw') })
      // наградный тост после основного (провайдер кита держит один тост)
      notifyCreated()
    }
    emit('saved', task)
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  } finally {
    busy.value = false
  }
}

// Вставка изображений из буфера — только для существующей задачи.
// MdEditor сам вставляет markdown-ссылку в позицию курсора и показывает
// миниатюру; здесь только загрузка на сервер.
async function uploadImages(files: File[]): Promise<Attachment[]> {
  if (!props.task) return []
  uploading.value = true
  try {
    const saved = await api.uploadAttachments(props.task.document_id!, files)
    attachments.value.push(...saved)
    return saved
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
    return []
  } finally {
    uploading.value = false
  }
}

async function removeAttachment(att: Attachment) {
  try {
    await api.deleteAttachment(att.id)
    attachments.value = attachments.value.filter((a) => a.id !== att.id)
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

const projectOptions = () => [
  { value: '', label: t('common.noProject') },
  ...projects.value.map((p) => ({ value: String(p.id), label: p.name })),
]
</script>

<template>
  <form ref="formEl" class="edit-form" @submit.prevent="save">
    <GnInput v-model="form.title" :label="t('stack.form.title')" required />

    <!-- Тип задачи — важно, стоит сразу после заголовка -->
    <GnRadioGroup
      v-model="form.task_type"
      name="task_type"
      :label="t('stack.recur.legend')"
      :options="[
        { value: 'one_time', label: t('stack.recur.oneTime') },
        { value: 'recurring', label: t('stack.recur.recurring') },
      ]"
    />

    <MdEditor v-model="form.description" :upload-images="uploadImages" />

    <GnSelect
      v-model="form.project_id"
      :label="t('stack.form.project')"
      icon="ph-folder"
      :options="projectOptions()"
    />

    <!-- Теги: существующие выбираются, новое имя создаёт тег -->
    <GnTagInput
      v-model="selectedTags"
      :label="t('stack.form.tags')"
      :placeholder="t('stack.form.tagsPlaceholder')"
      unique
      @add="onTagAdd"
    />

    <!-- Приоритет — понятная шкала с цветом (в БД число 0–10) -->
    <GnSelect
      v-model="form.priorityGrade"
      :label="t('stack.form.priority')"
      icon="ph-flag"
      :options="[{ value: '', label: t('common.none') }, ...priorityOptions()]"
    />
    <!-- Оценки и дедлайн — «дополнительно»: при создании свёрнуто (детализация
         происходит позже, на странице задачи), при правке раскрыто -->
    <GnAccordion v-model="extraOpen" :items="extraItems">
      <template #extra>
        <fieldset class="tags-fieldset">
          <legend>{{ t('stack.form.estimateLegend') }}</legend>
          <DurationInput
            v-model="form.estimated_minutes"
            :label="t('stack.form.estimate')"
            :min="1"
          />
          <GnInput
            v-model.number="form.cost_estimate_money"
            :label="t('stack.form.cost')"
            type="number"
            min="0"
          />
        </fieldset>

        <fieldset class="tags-fieldset">
          <legend>{{ t('stack.deadline.legend') }}</legend>
          <GnInput v-model="form.deadline_date" :label="t('stack.deadline.date')" type="date" />
          <GnSelect
            v-model="form.deadline_period"
            :label="t('stack.deadline.period')"
            :options="[{ value: '', label: t('common.none') }, ...deadlinePeriodOptions()]"
          />
        </fieldset>
      </template>
    </GnAccordion>

    <fieldset v-if="form.task_type === 'recurring'" class="tags-fieldset">
      <legend>{{ t('stack.recur.kind') }}</legend>
      <GnSelect
        v-model="form.recur_kind"
        :options="[{ value: '', label: t('common.choose') }, ...recurKindOptions()]"
      />
      <GnInput
        v-if="form.recur_kind === 'interval'"
        v-model.number="form.recur_interval_days"
        :label="t('stack.recur.interval')"
        type="number"
        min="1"
        max="365"
      />
      <template v-if="form.recur_kind === 'weekdays'">
        <fieldset class="tags-fieldset weekdays-fieldset">
          <legend>{{ t('stack.recur.weekdays') }}</legend>
          <GnCheckbox
            v-for="d in WEEKDAYS"
            :key="d.value"
            v-model="form.recurWeekday[d.value]"
            :label="d.label"
          />
        </fieldset>
      </template>
      <GnInput
        v-if="form.recur_kind === 'monthly'"
        v-model.number="form.recur_day_of_month"
        :label="t('stack.recur.monthlyDay')"
        type="number"
        min="1"
        max="31"
      />
    </fieldset>

    <!-- Вложения: вставленные Ctrl+V изображения -->
    <div v-if="attachments.length || uploading" class="attachments">
      <GnBadge v-if="uploading" variant="info">{{ t('stack.uploading') }}</GnBadge>
      <div class="thumbs">
        <div v-for="att in attachments" :key="att.id" class="thumb">
          <img :src="api.attachmentUrl(att.id)" :alt="att.original_name" />
          <GnIconButton
            icon="ph-x"
            :label="t('stack.deleteAttachment')"
            size="sm"
            @click="removeAttachment(att)"
          />
        </div>
      </div>
    </div>

    <div class="form-actions">
      <GnButton type="submit" variant="success" icon="ph-check-circle" :disabled="busy">
        {{ submitText }}
      </GnButton>
      <GnButton type="button" variant="primary" @click="emit('cancel')">
        {{ t('common.cancel') }}
      </GnButton>
    </div>
  </form>
</template>

<style scoped>
.edit-form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  max-width: 100%;
}
/* у кита .form-group ограничена 600px — в drawer'ах это ширина панели, а на
   странице задачи форма должна занимать весь контейнер целиком */
.edit-form :deep(.form-group) {
  max-width: 100%;
}
.tags-fieldset {
  border: 1px solid var(--border, #333);
  padding: 0.75rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  align-items: center;
}
.tags-fieldset legend {
  padding: 0 0.5rem;
  font-size: 0.85em;
  color: var(--text-muted, #888);
}
/* панель «Дополнительно»: группы (оценка, дедлайн) — столбцом с тем же шагом,
   что и остальные блоки формы */
.edit-form :deep(.accordion-panel) {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}
.attachments {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}
.thumbs {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
}
.thumb {
  position: relative;
}
.thumb img {
  max-width: 120px;
  max-height: 90px;
  border: 1px solid var(--border, #333);
  display: block;
}
.thumb .btn-icon {
  position: absolute;
  top: 4px;
  right: 4px;
}
.form-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
}
</style>