<script setup lang="ts">
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, priorityOptions, gradeToPriority, priorityToGrade, weekdayShort } from '../taskui'
import { useToast } from 'gnexus-ui-kit/vue'
import MdEditor from './MdEditor.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[]>([])
// Дни недели для правила повторения (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) 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 selectedTags.value
.map((name) => tags.value.find((tag) => tag.name === name)?.id)
.filter((id): id is number => typeof id === 'number')
}
// Новый тег, введённый в поле, — сразу в каталог
async function onTagAdd(name: string) {
if (tags.value.some((tag) => tag.name === name)) return
try {
tags.value.push(await api.createTag(name))
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
}
}
const submitText = computed(() => props.submitLabel ?? t(props.task ? 'common.save' : 'common.create'))
async function save() {
try {
const f = form.value
const patch = {
title: f.title,
description: f.description,
project_id: f.project_id === '' ? null : Number(f.project_id),
tag_ids: selectedTagIds(),
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') })
}
emit('saved', task)
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
}
}
// Вставка изображений из буфера обмена — только для существующей задачи
async function onPaste(event: ClipboardEvent) {
if (!props.task) return
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) })
}
}
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 />
<!-- Тип задачи — важно, стоит сразу после заголовка -->
<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" @paste="onPaste" />
<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()]"
/>
<!-- Оценки (время и деньги) — группа по образцу дедлайна -->
<fieldset class="tags-fieldset">
<legend>{{ t('stack.form.estimateLegend') }}</legend>
<GnInput
v-model.number="form.estimated_minutes"
:label="t('stack.form.estimate')"
type="number"
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>
<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">
{{ submitText }}
</GnButton>
<GnButton type="button" variant="secondary" class="btn-cancel" @click="emit('cancel')">
{{ t('common.cancel') }}
</GnButton>
</div>
</form>
</template>
<style scoped>
.edit-form {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 640px;
}
.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);
}
.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;
}
/* Отмена — без рамки, менее заметная, чем вторичная */
.btn-cancel {
border-color: transparent;
color: var(--text-muted, #888);
}
.btn-cancel:hover {
border-color: transparent;
color: var(--accent, #7aa2f7);
}
</style>