1
0

Большое обновление

1. Создание личных проектов
2. Управление командой
3. Приглашение участников
4. Уведомления

и многое другое...
This commit is contained in:
2026-01-18 20:17:02 +07:00
parent 250eac70a7
commit 190b4d0a5e
51 changed files with 6179 additions and 426 deletions

View File

@@ -1,9 +1,6 @@
<template>
<SlidePanel
:show="show"
:width="500"
:min-width="400"
:max-width="700"
@close="handleClose"
>
<template #header>

View File

@@ -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)

View File

@@ -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

View File

@@ -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()