525 lines
13 KiB
Vue
525 lines
13 KiB
Vue
<template>
|
||
<SlidePanel
|
||
:show="show"
|
||
@close="tryClose"
|
||
>
|
||
<template #header>
|
||
<div class="header-title-block">
|
||
<h2>{{ panelTitle }}</h2>
|
||
<span v-if="!isNew && card?.dateCreate" class="header-date">
|
||
Создано: {{ formatDate(card.dateCreate) }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Вкладки (только для существующих задач) -->
|
||
<TabsPanel
|
||
v-if="!isNew"
|
||
v-model="activeTab"
|
||
:tabs="tabsConfig"
|
||
class="header-tabs"
|
||
/>
|
||
</template>
|
||
|
||
<template #default>
|
||
<!-- Вкладка редактирования -->
|
||
<TaskEditTab
|
||
v-show="activeTab === 'edit' || isNew"
|
||
ref="editTabRef"
|
||
:card="card"
|
||
:departments="departments"
|
||
:labels="labels"
|
||
:users="users"
|
||
@preview-image="openImagePreview"
|
||
/>
|
||
|
||
<!-- Вкладка комментариев -->
|
||
<TaskCommentsTab
|
||
v-show="activeTab === 'comments'"
|
||
ref="commentsTabRef"
|
||
:task-id="card?.id"
|
||
:current-user-id="currentUserId"
|
||
:is-project-admin="isProjectAdmin"
|
||
:active="activeTab === 'comments'"
|
||
@comments-loaded="commentsCount = $event"
|
||
@preview-file="openImagePreview"
|
||
/>
|
||
</template>
|
||
|
||
<!-- Footer: скрываем на вкладке комментариев -->
|
||
<template #footer v-if="activeTab !== 'comments'">
|
||
<div class="footer-left">
|
||
<IconButton
|
||
v-if="!isNew"
|
||
icon="trash-2"
|
||
variant="danger"
|
||
title="Удалить"
|
||
@click="handleDelete"
|
||
/>
|
||
<IconButton
|
||
v-if="!isNew && canArchive && !isArchived"
|
||
icon="archive"
|
||
variant="warning"
|
||
title="В архив"
|
||
@click="handleArchive"
|
||
/>
|
||
<IconButton
|
||
v-if="!isNew && isArchived"
|
||
icon="archive-restore"
|
||
variant="warning"
|
||
title="Из архива"
|
||
@click="handleRestore"
|
||
/>
|
||
</div>
|
||
<div class="footer-right">
|
||
<button class="btn-cancel" @click="tryClose">Отмена</button>
|
||
<button
|
||
class="btn-save"
|
||
@click="handleSave"
|
||
:disabled="!canSave || isSaving"
|
||
>
|
||
<span v-if="isSaving" class="btn-loader"></span>
|
||
<span v-else>{{ isNew ? 'Создать' : 'Сохранить' }}</span>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</SlidePanel>
|
||
|
||
<!-- Диалог несохранённых изменений -->
|
||
<ConfirmDialog
|
||
:show="showUnsavedDialog"
|
||
title="Обнаружены изменения"
|
||
message="У вас есть несохранённые изменения.<br>Что вы хотите сделать?"
|
||
confirm-text="Сохранить"
|
||
:show-discard="true"
|
||
@confirm="confirmSave"
|
||
@cancel="cancelClose"
|
||
@discard="confirmDiscard"
|
||
/>
|
||
|
||
<!-- Диалог удаления задачи -->
|
||
<ConfirmDialog
|
||
:show="showDeleteDialog"
|
||
title="Удалить задачу?"
|
||
message="Это действие нельзя отменить.<br>Задача будет удалена навсегда."
|
||
confirm-text="Удалить"
|
||
variant="danger"
|
||
@confirm="confirmDelete"
|
||
@cancel="showDeleteDialog = false"
|
||
/>
|
||
|
||
<!-- Диалог архивации задачи -->
|
||
<ConfirmDialog
|
||
:show="showArchiveDialog"
|
||
title="Архивировать задачу?"
|
||
message="Задача будет перемещена в архив.<br>Вы сможете восстановить её позже."
|
||
confirm-text="В архив"
|
||
variant="warning"
|
||
@confirm="confirmArchive"
|
||
@cancel="showArchiveDialog = false"
|
||
/>
|
||
|
||
<!-- Диалог разархивации задачи -->
|
||
<ConfirmDialog
|
||
:show="showRestoreDialog"
|
||
title="Вернуть из архива?"
|
||
message="Задача будет возвращена на доску<br>в колонку «Готово»."
|
||
confirm-text="Вернуть"
|
||
variant="warning"
|
||
@confirm="confirmRestore"
|
||
@cancel="showRestoreDialog = false"
|
||
/>
|
||
|
||
<!-- Модальное окно просмотра изображения -->
|
||
<ImagePreview
|
||
:file="previewImage"
|
||
:get-full-url="getFullUrl"
|
||
:show-delete="previewImage?.source === 'comment' ? previewImage?.canDelete : true"
|
||
@close="closeImagePreview"
|
||
@delete="deleteFromPreview"
|
||
/>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch, onMounted, onUpdated, nextTick } from 'vue'
|
||
import SlidePanel from '../ui/SlidePanel.vue'
|
||
import TabsPanel from '../ui/TabsPanel.vue'
|
||
import IconButton from '../ui/IconButton.vue'
|
||
import ImagePreview from '../ui/ImagePreview.vue'
|
||
import ConfirmDialog from '../ConfirmDialog.vue'
|
||
import TaskEditTab from './TaskEditTab.vue'
|
||
import TaskCommentsTab from './TaskCommentsTab.vue'
|
||
import { taskImageApi, commentImageApi, getFullUrl } from '../../api'
|
||
import { useMobile } from '../../composables/useMobile'
|
||
|
||
const { isMobile } = useMobile()
|
||
|
||
// Состояние загрузки для кнопки сохранения
|
||
const isSaving = ref(false)
|
||
|
||
const props = defineProps({
|
||
show: Boolean,
|
||
card: Object,
|
||
columnId: [String, Number],
|
||
doneColumnId: Number,
|
||
isArchived: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
departments: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
labels: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
users: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
currentUserId: {
|
||
type: Number,
|
||
default: null
|
||
},
|
||
isProjectAdmin: {
|
||
type: Boolean,
|
||
default: false
|
||
},
|
||
// Callback для сохранения (возвращает Promise)
|
||
onSave: {
|
||
type: Function,
|
||
default: null
|
||
}
|
||
})
|
||
|
||
const emit = defineEmits(['close', 'save', 'delete', 'archive', 'restore'])
|
||
|
||
// State
|
||
const isNew = ref(true)
|
||
const activeTab = ref('edit')
|
||
const commentsCount = ref(0)
|
||
|
||
// Refs
|
||
const editTabRef = ref(null)
|
||
const commentsTabRef = ref(null)
|
||
|
||
// Dialogs
|
||
const showUnsavedDialog = ref(false)
|
||
const showDeleteDialog = ref(false)
|
||
const showArchiveDialog = ref(false)
|
||
const showRestoreDialog = ref(false)
|
||
|
||
// Image preview
|
||
const previewImage = ref(null)
|
||
|
||
// Panel title
|
||
const panelTitle = computed(() => {
|
||
if (isNew.value) return 'Новая задача'
|
||
if (activeTab.value === 'comments') return 'Комментарии'
|
||
return 'Редактирование'
|
||
})
|
||
|
||
// Tabs config
|
||
const tabsConfig = computed(() => [
|
||
{ id: 'edit', icon: 'pencil', title: 'Редактирование' },
|
||
{ id: 'comments', icon: 'message-circle', title: 'Комментарии', badge: commentsCount.value || null }
|
||
])
|
||
|
||
// Can save
|
||
const canSave = computed(() => {
|
||
const form = editTabRef.value?.form
|
||
return form?.title?.trim() && form?.departmentId
|
||
})
|
||
|
||
// Can archive (только если колонка "Готово")
|
||
const canArchive = computed(() => {
|
||
return props.doneColumnId && Number(props.columnId) === props.doneColumnId
|
||
})
|
||
|
||
// Format date
|
||
const formatDate = (dateStr) => {
|
||
if (!dateStr) return ''
|
||
const date = new Date(dateStr)
|
||
const day = date.getDate()
|
||
const months = ['января', 'февраля', 'марта', 'апреля', 'мая', 'июня', 'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря']
|
||
return `${day} ${months[date.getMonth()]} ${date.getFullYear()}`
|
||
}
|
||
|
||
// Close handling
|
||
const tryClose = () => {
|
||
if (editTabRef.value?.hasChanges) {
|
||
showUnsavedDialog.value = true
|
||
} else {
|
||
emit('close')
|
||
}
|
||
}
|
||
|
||
const confirmSave = () => {
|
||
showUnsavedDialog.value = false
|
||
handleSave()
|
||
}
|
||
|
||
const confirmDiscard = () => {
|
||
showUnsavedDialog.value = false
|
||
emit('close')
|
||
}
|
||
|
||
const cancelClose = () => {
|
||
showUnsavedDialog.value = false
|
||
}
|
||
|
||
// Save
|
||
const handleSave = async () => {
|
||
if (!canSave.value || isSaving.value) return
|
||
|
||
isSaving.value = true
|
||
editTabRef.value.fileError = ''
|
||
|
||
try {
|
||
if (props.card?.id) {
|
||
// Upload new files
|
||
const newFiles = editTabRef.value.getNewFiles()
|
||
for (const file of newFiles) {
|
||
const result = await taskImageApi.upload(props.card.id, file.preview, file.name)
|
||
if (result.success) {
|
||
file.isNew = false
|
||
file.name = result.file.name
|
||
file.preview = result.file.url
|
||
} else {
|
||
editTabRef.value.fileError = result.errors?.file || 'Ошибка загрузки файла'
|
||
return
|
||
}
|
||
}
|
||
|
||
// Delete marked files
|
||
const filesToDelete = editTabRef.value.getFilesToDelete()
|
||
if (filesToDelete.length > 0) {
|
||
const fileNames = filesToDelete.map(f => f.name)
|
||
const result = await taskImageApi.delete(props.card.id, fileNames)
|
||
if (!result.success) {
|
||
editTabRef.value.fileError = result.errors?.file || 'Ошибка удаления файла'
|
||
return
|
||
}
|
||
}
|
||
|
||
editTabRef.value.removeDeletedFiles()
|
||
}
|
||
|
||
const formData = editTabRef.value.getFormData()
|
||
const taskData = {
|
||
...formData,
|
||
id: props.card?.id
|
||
}
|
||
|
||
// Вызываем callback и ждём завершения
|
||
if (props.onSave) {
|
||
await props.onSave(taskData)
|
||
} else {
|
||
emit('save', taskData)
|
||
}
|
||
} finally {
|
||
isSaving.value = false
|
||
}
|
||
}
|
||
|
||
// Delete
|
||
const handleDelete = () => {
|
||
showDeleteDialog.value = true
|
||
}
|
||
|
||
const confirmDelete = () => {
|
||
showDeleteDialog.value = false
|
||
emit('delete', props.card.id)
|
||
}
|
||
|
||
// Archive
|
||
const handleArchive = () => {
|
||
showArchiveDialog.value = true
|
||
}
|
||
|
||
const confirmArchive = () => {
|
||
showArchiveDialog.value = false
|
||
if (props.card?.id) {
|
||
emit('archive', props.card.id)
|
||
}
|
||
}
|
||
|
||
// Restore
|
||
const handleRestore = () => {
|
||
showRestoreDialog.value = true
|
||
}
|
||
|
||
const confirmRestore = () => {
|
||
showRestoreDialog.value = false
|
||
if (props.card?.id) {
|
||
emit('restore', props.card.id)
|
||
}
|
||
}
|
||
|
||
// Image preview
|
||
const openImagePreview = (file) => {
|
||
previewImage.value = file
|
||
nextTick(refreshIcons)
|
||
}
|
||
|
||
const closeImagePreview = () => {
|
||
previewImage.value = null
|
||
}
|
||
|
||
const deleteFromPreview = async () => {
|
||
if (!previewImage.value) return
|
||
|
||
// Удаление файла из комментария
|
||
if (previewImage.value.source === 'comment') {
|
||
const { commentId, name } = previewImage.value
|
||
try {
|
||
const result = await commentImageApi.delete(commentId, name)
|
||
if (result.success) {
|
||
closeImagePreview()
|
||
// Перезагружаем комментарии
|
||
commentsTabRef.value?.loadComments()
|
||
}
|
||
} catch (e) {
|
||
console.error('Ошибка удаления файла:', e)
|
||
}
|
||
return
|
||
}
|
||
|
||
// Удаление файла из задачи (старая логика)
|
||
const files = editTabRef.value?.attachedFiles || []
|
||
const index = files.findIndex(
|
||
f => f.name === previewImage.value.name && f.size === previewImage.value.size
|
||
)
|
||
if (index !== -1) {
|
||
closeImagePreview()
|
||
// Trigger file remove in edit tab
|
||
editTabRef.value?.handleFileRemove?.(index)
|
||
}
|
||
}
|
||
|
||
// Icons
|
||
const refreshIcons = () => {
|
||
if (window.lucide) {
|
||
window.lucide.createIcons()
|
||
}
|
||
}
|
||
|
||
// Watch show
|
||
watch(() => props.show, async (newVal) => {
|
||
if (newVal) {
|
||
isNew.value = !props.card
|
||
activeTab.value = 'edit'
|
||
commentsCount.value = props.card?.comments_count || 0
|
||
previewImage.value = null
|
||
isSaving.value = false // Сброс состояния кнопки сохранения
|
||
|
||
// Reset comments tab
|
||
commentsTabRef.value?.reset()
|
||
|
||
// Load form data
|
||
await nextTick()
|
||
editTabRef.value?.loadFromCard(props.card)
|
||
editTabRef.value?.setDetailsContent(props.card?.details || '')
|
||
|
||
await nextTick()
|
||
editTabRef.value?.saveInitialForm()
|
||
// Не фокусируем поле автоматически, чтобы не открывалась клавиатура
|
||
refreshIcons()
|
||
}
|
||
})
|
||
|
||
onMounted(refreshIcons)
|
||
onUpdated(refreshIcons)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.header-title-block {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.header-title-block h2 {
|
||
margin: 0;
|
||
}
|
||
|
||
.header-date {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.header-tabs {
|
||
margin-right: 12px;
|
||
}
|
||
|
||
.footer-left {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.footer-right {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.btn-cancel {
|
||
padding: 12px 24px;
|
||
background: rgba(255, 255, 255, 0.04);
|
||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||
border-radius: 8px;
|
||
color: var(--text-secondary);
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.btn-cancel:hover {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.btn-save {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 120px;
|
||
padding: 12px 28px;
|
||
background: var(--accent);
|
||
border: none;
|
||
border-radius: 8px;
|
||
color: #000;
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.btn-save:hover {
|
||
filter: brightness(1.1);
|
||
}
|
||
|
||
.btn-save:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-loader {
|
||
display: inline-block;
|
||
width: 18px;
|
||
height: 18px;
|
||
border: 2px solid rgba(0, 0, 0, 0.3);
|
||
border-top-color: #000;
|
||
border-radius: 50%;
|
||
animation: spin 0.7s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
</style>
|