Newer
Older
gnexus-tasks / frontend / src / components / TaskForm.vue
<script setup lang="ts">
import DOMPurify from 'dompurify'
import { marked } from 'marked'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api, type Attachment, type Project, type Tag, type Task } from '../api'
import { deadlinePeriodOptions, recurKindOptions, weekdayShort } from '../taskui'
import { useToast } from 'gnexus-ui-kit/vue'
import QuickPrompt from './QuickPrompt.vue'

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

const props = defineProps<{
  task: Task
  /** После сохранения утвердить задачу (флоу стека: правка = утверждение) */
  approveAfterSave?: boolean
  /** Надпись на кнопке подтверждения */
  submitLabel?: string
}>()

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: '',
  description: '',
  project_id: '' as string | number,
  tagSelection: {} as Record<number, boolean>,
  priority: null as number | null,
  estimated_minutes: null as number | null,
  budget_money: null as number | null,
  cost_estimate_money: null as number | null,
  deadline_date: '' as string,
  deadline_period: '' as string,
  task_type: 'one_time',
  recur_kind: '' as string,
  recur_interval_days: 1 as number | null,
  recurWeekday: {} as Record<string, boolean>,
  recur_day_of_month: 1 as number | null,
})

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

const markdownPreview = computed(() =>
  DOMPurify.sanitize(marked.parse(form.value.description, { async: false })),
)

onMounted(async () => {
  const task = props.task
  const selection: Record<number, boolean> = {}
  task.tags.forEach((t) => (selection[t.id] = true))
  form.value = {
    title: task.title,
    description: task.description,
    project_id: task.project?.id ?? '',
    tagSelection: selection,
    priority: task.priority,
    estimated_minutes: task.estimated_minutes,
    budget_money: task.budget_money,
    cost_estimate_money: task.cost_estimate_money,
    deadline_date: task.deadline_date ?? '',
    deadline_period: task.deadline_period ?? '',
    task_type: task.task_type ?? 'one_time',
    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,
  }
  try {
    ;[projects.value, tags.value] = await Promise.all([api.listProjects(), api.listTags()])
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
  void loadAttachments(task.id)
})

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

function selectedTagIds(): number[] {
  return Object.entries(form.value.tagSelection)
    .filter(([, on]) => on)
    .map(([id]) => Number(id))
}

async function save() {
  try {
    const f = form.value
    let task = await api.updateTask(props.task.id, {
      title: f.title,
      description: f.description,
      project_id: f.project_id === '' ? null : Number(f.project_id),
      tag_ids: selectedTagIds(),
      priority: f.priority,
      estimated_minutes: f.estimated_minutes,
      budget_money: f.budget_money,
      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,
    })
    if (props.approveAfterSave) task = await api.approveTask(props.task.id)
    toast.success({ title: props.approveAfterSave ? t('common.taskApproved') : t('common.saved') })
    emit('saved', task)
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  }
}

// Вставка изображений из буфера обмена прямо в редактор описания
async function onPaste(event: ClipboardEvent) {
  const files = Array.from(event.clipboardData?.files ?? []).filter((f) =>
    f.type.startsWith('image/'),
  )
  if (!files.length) return
  event.preventDefault()
  uploading.value = true
  try {
    const saved = await api.uploadAttachments(props.task.id, files)
    attachments.value.push(...saved)
  } catch (e) {
    toast.error({ title: t('common.error'), text: String(e) })
  } 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) })
  }
}

// Создание проекта/тега на лету — модальное окно вместо window.prompt
const promptOpen = ref(false)
const promptTitle = ref('')
let promptKind: 'project' | 'tag' = 'project'

function openPrompt(kind: 'project' | 'tag') {
  promptKind = kind
  promptTitle.value = kind === 'project' ? t('stack.promptProject') : t('stack.promptTag')
  promptOpen.value = true
}

async function onPromptConfirm(name: string) {
  try {
    if (promptKind === 'project') {
      const p = await api.createProject(name)
      projects.value.push(p)
      form.value.project_id = p.id
    } else {
      const tag = await api.createTag(name)
      tags.value.push(tag)
      form.value.tagSelection[tag.id] = true
    }
  } 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 class="edit-form" @submit.prevent="save">
    <GnInput v-model="form.title" :label="t('stack.form.title')" required />
    <GnTextarea v-model="form.description" :label="t('stack.form.description')" :rows="4" @paste="onPaste" />
    <GnCheckbox v-model="showPreview" :label="t('stack.form.preview')" />
    <div v-if="showPreview" class="md-preview" v-html="markdownPreview" />

    <GnSelect
      v-model="form.project_id"
      :label="t('stack.form.project')"
      icon="ph-folder"
      :options="projectOptions()"
    />
    <div class="form-row-actions">
      <GnButton type="button" variant="secondary" size="sm" icon="ph-plus" @click="openPrompt('project')">
        {{ t('stack.newProject') }}
      </GnButton>
    </div>
    <fieldset class="tags-fieldset">
      <legend>{{ t('stack.form.tags') }}</legend>
      <GnCheckbox v-for="tag in tags" :key="tag.id" v-model="form.tagSelection[tag.id]" :label="tag.name" />
      <GnButton type="button" variant="secondary" size="sm" icon="ph-plus" @click="openPrompt('tag')">
        {{ t('stack.newTag') }}
      </GnButton>
    </fieldset>
    <GnInput v-model.number="form.priority" :label="t('stack.form.priority')" type="number" />
    <GnInput
      v-model.number="form.estimated_minutes"
      :label="t('stack.form.estimate')"
      type="number"
      min="1"
    />
    <div class="budget-row">
      <GnInput v-model.number="form.budget_money" :label="t('stack.form.budget')" type="number" min="0" />
      <GnInput
        v-model.number="form.cost_estimate_money"
        :label="t('stack.form.cost')"
        type="number"
        min="0"
      />
    </div>
    <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>
    <fieldset class="tags-fieldset">
      <legend>{{ t('stack.recur.legend') }}</legend>
      <GnSelect
        v-model="form.task_type"
        :options="[
          { value: 'one_time', label: t('stack.recur.oneTime') },
          { value: 'recurring', label: t('stack.recur.recurring') },
        ]"
      />
      <template v-if="form.task_type === 'recurring'">
        <GnSelect
          v-model="form.recur_kind"
          :label="t('stack.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"
        />
      </template>
    </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="accent" icon="ph-check-circle">
        {{ submitLabel ?? t('stack.approve') }}
      </GnButton>
      <GnButton type="button" variant="secondary" @click="emit('cancel')">
        {{ t('common.cancel') }}
      </GnButton>
    </div>
  </form>

  <QuickPrompt v-model:open="promptOpen" :title="promptTitle" @confirm="onPromptConfirm" />
</template>

<style scoped>
.edit-form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}
.form-row-actions {
  margin-top: -0.5rem;
}
.tags-fieldset {
  border: 1px solid var(--border, #333);
  border-radius: 6px;
  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);
}
.md-preview {
  border: 1px solid var(--border, #333);
  border-radius: 6px;
  padding: 0.75rem;
  overflow-x: auto;
}
.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-radius: 6px;
  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;
}
.budget-row {
  display: flex;
  gap: 0.75rem;
}
.budget-row > * {
  flex: 1;
}
</style>