Большое обновление
1. Создание личных проектов 2. Управление командой 3. Приглашение участников 4. Уведомления и многое другое...
This commit is contained in:
@@ -1,9 +1,6 @@
|
||||
<template>
|
||||
<SlidePanel
|
||||
:show="show"
|
||||
:width="500"
|
||||
:min-width="400"
|
||||
:max-width="700"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template #header>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Кнопка добавления комментария -->
|
||||
<div class="comment-action-bar">
|
||||
<div v-if="canComment" class="comment-action-bar">
|
||||
<button class="btn-new-comment" @click="openEditorForCreate">
|
||||
<i data-lucide="message-square-plus"></i>
|
||||
Написать комментарий
|
||||
@@ -93,6 +93,10 @@ const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
canComment: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -242,7 +246,7 @@ const loadComments = async (silent = false) => {
|
||||
// Автообновление
|
||||
const startRefresh = () => {
|
||||
stopRefresh()
|
||||
const interval = (window.APP_CONFIG?.IDLE_REFRESH_SECONDS || 30) * 1000
|
||||
const interval = (window.APP_CONFIG?.REFRESH_INTERVALS?.comments || 30) * 1000
|
||||
refreshInterval = setInterval(async () => {
|
||||
if (props.active && props.taskId) {
|
||||
await loadComments(true)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
v-model="form.title"
|
||||
placeholder="Введите название задачи"
|
||||
ref="titleInputRef"
|
||||
:readonly="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -12,11 +13,12 @@
|
||||
<TextInput
|
||||
v-model="form.description"
|
||||
placeholder="Краткое описание в одну строку..."
|
||||
:readonly="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Подробное описание">
|
||||
<template #actions>
|
||||
<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>
|
||||
@@ -33,6 +35,7 @@
|
||||
v-model="form.details"
|
||||
placeholder="Подробное описание задачи, заметки, ссылки..."
|
||||
:show-toolbar="false"
|
||||
:disabled="!canEdit"
|
||||
ref="detailsEditorRef"
|
||||
/>
|
||||
</FormField>
|
||||
@@ -41,6 +44,7 @@
|
||||
<TagsSelect
|
||||
v-model="form.departmentId"
|
||||
:options="departmentOptions"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -48,12 +52,13 @@
|
||||
<TagsSelect
|
||||
v-model="form.labelId"
|
||||
:options="labelOptions"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div class="field-row" :class="{ mobile: isMobile }">
|
||||
<FormField label="Срок выполнения">
|
||||
<DatePicker v-model="form.dueDate" />
|
||||
<DatePicker v-model="form.dueDate" :disabled="!canEdit" />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Исполнитель">
|
||||
@@ -63,18 +68,21 @@
|
||||
searchable
|
||||
placeholder="Без исполнителя"
|
||||
empty-label="Без исполнителя"
|
||||
:disabled="!canEdit"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
v-if="canEdit || attachedFiles.length > 0"
|
||||
label="Прикреплённые файлы"
|
||||
hint="Разрешены: PNG, JPEG, JPG, ZIP, RAR (до 10 МБ)"
|
||||
: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)"
|
||||
@@ -126,6 +134,10 @@ const props = defineProps({
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
canEdit: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -173,13 +185,32 @@ const labelOptions = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
// Данные исполнителя из карточки (может быть удалённый участник)
|
||||
const cardAssignee = ref(null)
|
||||
|
||||
const userOptions = computed(() => {
|
||||
return props.users.map(user => ({
|
||||
value: user.id,
|
||||
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
|
||||
@@ -216,6 +247,7 @@ const resetForm = () => {
|
||||
form.labelId = 2 // Нормально по умолчанию
|
||||
form.dueDate = new Date().toISOString().split('T')[0]
|
||||
form.userId = null
|
||||
cardAssignee.value = null
|
||||
clearFiles()
|
||||
}
|
||||
|
||||
@@ -234,6 +266,16 @@ const loadFromCard = (card) => {
|
||||
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,
|
||||
@@ -256,8 +298,15 @@ const applyFormat = (command) => {
|
||||
|
||||
const getAvatarByUserId = (userId) => {
|
||||
if (!userId) return null
|
||||
const user = props.users.find(u => u.id === userId)
|
||||
return user ? user.avatar_url : 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
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
:departments="store.departments"
|
||||
:labels="store.labels"
|
||||
:users="store.users"
|
||||
:can-edit="canEdit"
|
||||
@preview-image="openImagePreview"
|
||||
/>
|
||||
|
||||
@@ -42,13 +43,14 @@
|
||||
:current-user-avatar="store.currentUserAvatar"
|
||||
:is-project-admin="store.isProjectAdmin"
|
||||
:active="activeTab === 'comments'"
|
||||
:can-comment="canComment"
|
||||
@comments-loaded="commentsCount = $event"
|
||||
@preview-file="openImagePreview"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Footer: скрываем на вкладке комментариев -->
|
||||
<template #footer v-if="activeTab !== 'comments'">
|
||||
<!-- Footer: скрываем на вкладке комментариев или если нет прав редактировать -->
|
||||
<template #footer v-if="activeTab !== 'comments' && canEdit">
|
||||
<div class="footer-left">
|
||||
<IconButton
|
||||
v-if="!isNew"
|
||||
@@ -143,9 +145,11 @@ import { useMobile } from '../../composables/useMobile'
|
||||
import { useLucideIcons } from '../../composables/useLucideIcons'
|
||||
import { useDateFormat } from '../../composables/useDateFormat'
|
||||
import { useProjectsStore } from '../../stores/projects'
|
||||
import { useToast } from '../../composables/useToast'
|
||||
|
||||
const { isMobile } = useMobile()
|
||||
const { refreshIcons } = useLucideIcons()
|
||||
const toast = useToast()
|
||||
const { formatFull } = useDateFormat()
|
||||
const store = useProjectsStore()
|
||||
|
||||
@@ -203,7 +207,7 @@ const previewImage = ref(null)
|
||||
const panelTitle = computed(() => {
|
||||
if (isNew.value) return 'Новая задача'
|
||||
if (activeTab.value === 'comments') return 'Комментарии'
|
||||
return 'Редактирование'
|
||||
return canEdit.value ? 'Редактирование' : 'Просмотр'
|
||||
})
|
||||
|
||||
// Tabs config
|
||||
@@ -223,6 +227,18 @@ const canArchive = computed(() => {
|
||||
return store.doneColumnId && Number(props.columnId) === store.doneColumnId
|
||||
})
|
||||
|
||||
// Право на редактирование (для новой — create_task, для существующей — canEditTask)
|
||||
const canEdit = computed(() => {
|
||||
if (isNew.value) return store.can('create_task')
|
||||
return store.canEditTask(props.card)
|
||||
})
|
||||
|
||||
// Право на создание комментариев
|
||||
const canComment = computed(() => {
|
||||
if (isNew.value) return false // В новой задаче нельзя комментировать
|
||||
return store.canCreateComment(props.card)
|
||||
})
|
||||
|
||||
// Close handling
|
||||
const tryClose = () => {
|
||||
if (editTabRef.value?.hasChanges) {
|
||||
@@ -295,6 +311,11 @@ const handleSave = async () => {
|
||||
} else {
|
||||
emit('save', taskData)
|
||||
}
|
||||
|
||||
toast.success(props.card?.id ? 'Задача сохранена' : 'Задача создана')
|
||||
} catch (error) {
|
||||
toast.error('Ошибка сохранения задачи')
|
||||
console.error('Error saving task:', error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
@@ -315,6 +336,8 @@ const confirmDelete = async () => {
|
||||
} else {
|
||||
emit('delete', props.card.id)
|
||||
}
|
||||
|
||||
toast.success('Задача удалена')
|
||||
}
|
||||
|
||||
// Archive
|
||||
@@ -332,6 +355,8 @@ const confirmArchive = async () => {
|
||||
} else {
|
||||
emit('archive', props.card.id)
|
||||
}
|
||||
|
||||
toast.success('Задача в архиве')
|
||||
}
|
||||
|
||||
// Restore
|
||||
@@ -349,6 +374,8 @@ const confirmRestore = async () => {
|
||||
} else {
|
||||
emit('restore', props.card.id)
|
||||
}
|
||||
|
||||
toast.success('Задача восстановлена')
|
||||
}
|
||||
|
||||
// Image preview
|
||||
@@ -401,6 +428,9 @@ watch(() => props.show, async (newVal) => {
|
||||
previewImage.value = null
|
||||
isSaving.value = false // Сброс состояния кнопки сохранения
|
||||
|
||||
// Обновляем права пользователя (могли измениться администратором)
|
||||
store.fetchUsers()
|
||||
|
||||
// Reset comments tab
|
||||
commentsTabRef.value?.reset()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user