1. Создание личных проектов 2. Управление командой 3. Приглашение участников 4. Уведомления и многое другое...
457 lines
12 KiB
Vue
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>
|