1
0
Files
TaskBoard/front_vue/src/components/TaskPanel/TaskEditTab.vue
Falknat 190b4d0a5e Большое обновление
1. Создание личных проектов
2. Управление командой
3. Приглашение участников
4. Уведомления

и многое другое...
2026-01-18 20:17:02 +07:00

457 lines
12 KiB
Vue

<template>
<div class="edit-tab">
<FormField label="Название">
<TextInput
v-model="form.title"
placeholder="Введите название задачи"
ref="titleInputRef"
:readonly="!canEdit"
/>
</FormField>
<FormField label="Краткое описание">
<TextInput
v-model="form.description"
placeholder="Краткое описание в одну строку..."
:readonly="!canEdit"
/>
</FormField>
<FormField label="Подробное описание">
<template v-if="canEdit" #actions>
<div class="format-buttons">
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный (Ctrl+B)">
<i data-lucide="bold"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив (Ctrl+I)">
<i data-lucide="italic"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание (Ctrl+U)">
<i data-lucide="underline"></i>
</button>
</div>
</template>
<RichTextEditor
v-model="form.details"
placeholder="Подробное описание задачи, заметки, ссылки..."
:show-toolbar="false"
:disabled="!canEdit"
ref="detailsEditorRef"
/>
</FormField>
<FormField label="Отдел">
<TagsSelect
v-model="form.departmentId"
:options="departmentOptions"
:disabled="!canEdit"
/>
</FormField>
<FormField label="Приоритет">
<TagsSelect
v-model="form.labelId"
:options="labelOptions"
:disabled="!canEdit"
/>
</FormField>
<div class="field-row" :class="{ mobile: isMobile }">
<FormField label="Срок выполнения">
<DatePicker v-model="form.dueDate" :disabled="!canEdit" />
</FormField>
<FormField label="Исполнитель">
<SelectDropdown
v-model="form.userId"
:options="userOptions"
searchable
placeholder="Без исполнителя"
empty-label="Без исполнителя"
:disabled="!canEdit"
/>
</FormField>
</div>
<FormField
v-if="canEdit || attachedFiles.length > 0"
label="Прикреплённые файлы"
:hint="canEdit ? 'Разрешены: PNG, JPEG, JPG, ZIP, RAR (до 10 МБ)' : ''"
:error="fileError"
>
<FileUploader
:files="attachedFiles"
:get-full-url="getFullUrl"
:read-only="!canEdit"
@add="handleFileAdd"
@remove="handleFileRemove"
@preview="$emit('preview-image', $event)"
@error="fileError = $event"
/>
</FormField>
<!-- Диалог удаления файла -->
<ConfirmDialog
:show="showDeleteFileDialog"
type="deleteFile"
@confirm="confirmDeleteFile"
@cancel="showDeleteFileDialog = false"
/>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, nextTick } from 'vue'
import FormField from '../ui/FormField.vue'
import TextInput from '../ui/TextInput.vue'
import RichTextEditor from '../ui/RichTextEditor.vue'
import SelectDropdown from '../ui/SelectDropdown.vue'
import TagsSelect from '../ui/TagsSelect.vue'
import FileUploader from '../ui/FileUploader.vue'
import DatePicker from '../DatePicker.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import { getFullUrl } from '../../api'
import { useMobile } from '../../composables/useMobile'
import { useLucideIcons } from '../../composables/useLucideIcons'
const { isMobile } = useMobile()
const { refreshIcons } = useLucideIcons()
const props = defineProps({
card: {
type: Object,
default: null
},
departments: {
type: Array,
default: () => []
},
labels: {
type: Array,
default: () => []
},
users: {
type: Array,
default: () => []
},
canEdit: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['preview-image'])
// Refs
const titleInputRef = ref(null)
const detailsEditorRef = ref(null)
// Form
const form = reactive({
title: '',
description: '',
details: '',
departmentId: null,
labelId: null,
dueDate: '',
userId: null
})
// Files
const attachedFiles = ref([])
const fileError = ref('')
const showDeleteFileDialog = ref(false)
const fileToDeleteIndex = ref(null)
// Initial state for change tracking
const initialForm = ref({})
const initialFilesCount = ref(0)
// Options for selects
const departmentOptions = computed(() => {
return props.departments.map(dept => ({
value: dept.id,
label: dept.name_departments,
color: dept.color
}))
})
const labelOptions = computed(() => {
return props.labels.map(label => ({
value: label.id,
label: label.name_labels,
icon: label.icon
}))
})
// Данные исполнителя из карточки (может быть удалённый участник)
const cardAssignee = ref(null)
const userOptions = computed(() => {
const options = props.users.map(user => ({
value: user.id_user, // id_user - это id пользователя из accounts
label: user.name,
subtitle: user.telegram,
avatar: getFullUrl(user.avatar_url)
}))
// Если текущий исполнитель не в списке участников — добавляем как виртуальную опцию
if (cardAssignee.value && form.userId) {
const exists = options.some(opt => Number(opt.value) === Number(form.userId))
if (!exists) {
options.unshift({
value: form.userId,
label: cardAssignee.value.name || 'Удалённый участник',
subtitle: '',
avatar: cardAssignee.value.avatar ? getFullUrl(cardAssignee.value.avatar) : null,
disabled: true // Нельзя выбрать повторно
})
}
}
return options
})
// Change tracking
const hasChanges = computed(() => {
return form.title !== initialForm.value.title ||
form.description !== initialForm.value.description ||
form.details !== initialForm.value.details ||
form.departmentId !== initialForm.value.departmentId ||
form.labelId !== initialForm.value.labelId ||
form.dueDate !== initialForm.value.dueDate ||
form.userId !== initialForm.value.userId ||
attachedFiles.value.length !== initialFilesCount.value
})
// Methods
const saveInitialForm = () => {
initialForm.value = {
title: form.title,
description: form.description,
details: form.details,
departmentId: form.departmentId,
labelId: form.labelId,
dueDate: form.dueDate,
userId: form.userId
}
initialFilesCount.value = attachedFiles.value.length
}
const resetForm = () => {
form.title = ''
form.description = ''
form.details = ''
form.departmentId = null
form.labelId = 2 // Нормально по умолчанию
form.dueDate = new Date().toISOString().split('T')[0]
form.userId = null
cardAssignee.value = null
clearFiles()
}
const clearFiles = () => {
attachedFiles.value = []
fileError.value = ''
}
const loadFromCard = (card) => {
if (card) {
form.title = card.title || ''
form.description = card.description || ''
form.details = card.details || ''
form.departmentId = card.departmentId || null
form.labelId = card.labelId || null
form.dueDate = card.dueDate || ''
form.userId = card.accountId || null
// Сохраняем данные исполнителя для случая если он удалён из проекта
if (card.accountId && card.assignee) {
cardAssignee.value = {
avatar: card.assignee,
name: null // Имя неизвестно, будет показываться аватарка
}
} else {
cardAssignee.value = null
}
if (card.files && card.files.length > 0) {
attachedFiles.value = card.files.map(f => ({
name: f.name,
size: f.size,
type: f.type,
preview: f.data || f.url,
isNew: false
}))
} else {
attachedFiles.value = []
}
} else {
resetForm()
}
}
const applyFormat = (command) => {
detailsEditorRef.value?.applyFormat(command)
}
const getAvatarByUserId = (userId) => {
if (!userId) return null
const user = props.users.find(u => Number(u.id_user) === Number(userId))
if (user) return user.avatar_url
// Fallback: если это тот же удалённый участник (userId не изменился), используем сохранённый аватар
if (cardAssignee.value && Number(userId) === Number(initialForm.value.userId)) {
return cardAssignee.value.avatar
}
return null
}
// File handlers
const handleFileAdd = async (file) => {
attachedFiles.value.push(file)
await nextTick()
refreshIcons()
}
const handleFileRemove = (index) => {
fileToDeleteIndex.value = index
showDeleteFileDialog.value = true
}
const confirmDeleteFile = () => {
if (fileToDeleteIndex.value !== null) {
const file = attachedFiles.value[fileToDeleteIndex.value]
if (file.isNew) {
attachedFiles.value.splice(fileToDeleteIndex.value, 1)
} else {
file.toDelete = true
}
}
showDeleteFileDialog.value = false
fileToDeleteIndex.value = null
}
// Get form data for saving
const getFormData = () => {
return {
title: form.title,
description: form.description,
details: form.details,
departmentId: form.departmentId,
labelId: form.labelId,
dueDate: form.dueDate,
accountId: form.userId,
assignee: getAvatarByUserId(form.userId),
files: attachedFiles.value
.filter(f => !f.toDelete)
.map(f => ({
name: f.name,
size: f.size,
type: f.type,
data: f.preview
}))
}
}
const getNewFiles = () => {
return attachedFiles.value.filter(f => f.isNew && !f.toDelete)
}
const getFilesToDelete = () => {
return attachedFiles.value.filter(f => f.toDelete && !f.isNew)
}
const removeDeletedFiles = () => {
attachedFiles.value = attachedFiles.value.filter(f => !f.toDelete)
}
const focusTitle = async () => {
await nextTick()
titleInputRef.value?.$el?.focus()
}
const setDetailsContent = (content) => {
detailsEditorRef.value?.setContent(content)
}
// Expose for parent
defineExpose({
form,
attachedFiles,
fileError,
hasChanges,
loadFromCard,
resetForm,
clearFiles,
saveInitialForm,
getFormData,
getNewFiles,
getFilesToDelete,
removeDeletedFiles,
focusTitle,
setDetailsContent
})
</script>
<style scoped>
.edit-tab {
display: flex;
flex-direction: column;
gap: 16px;
}
.field-row {
display: flex;
gap: 20px;
}
.field-row > :deep(.field) {
flex: 1;
}
.field-row.mobile {
flex-direction: column;
gap: 16px;
}
.format-buttons {
display: flex;
gap: 3px;
}
.format-btn {
width: 24px;
height: 24px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 5px;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.format-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
.format-btn:active {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.format-btn i {
width: 10px;
height: 10px;
}
</style>