1446 lines
41 KiB
Vue
1446 lines
41 KiB
Vue
<template>
|
||
<SlidePanel
|
||
:show="show"
|
||
@close="tryClose"
|
||
>
|
||
<template #header>
|
||
<h2>{{ isNew ? 'Новый проект' : 'Настройки проекта' }}</h2>
|
||
</template>
|
||
|
||
<template #default>
|
||
<!-- Название проекта -->
|
||
<div class="form-section">
|
||
<label class="form-label">Название проекта</label>
|
||
<TextInput
|
||
v-model="form.name"
|
||
placeholder="Введите название"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Колонки -->
|
||
<div class="form-section">
|
||
<label class="form-label">Колонки</label>
|
||
|
||
<div class="columns-list" ref="columnsListRef">
|
||
<div
|
||
v-for="(column, index) in form.columns"
|
||
:key="column.id || column.tempId"
|
||
class="column-item"
|
||
:class="{
|
||
'is-ready': isReadyColumn(column),
|
||
'is-dragging': !isMobile && !isLastColumn(index) && dragIndex === index,
|
||
'drag-over-top': !isMobile && dragOverIndex === index && dragPosition === 'top',
|
||
'drag-over-bottom': !isMobile && !isLastColumn(index) && dragOverIndex === index && dragPosition === 'bottom'
|
||
}"
|
||
:draggable="!isMobile && !isLastColumn(index)"
|
||
@dragstart="!isMobile && !isLastColumn(index) && handleColumnDragStart($event, index)"
|
||
@dragend="!isMobile && handleColumnDragEnd()"
|
||
@dragover.prevent="!isMobile && handleColumnDragOver($event, index)"
|
||
@dragleave="!isMobile && handleColumnDragLeave()"
|
||
@drop.prevent="!isMobile && handleColumnDrop(index)"
|
||
>
|
||
<!-- Desktop: drag handle (не показываем для последней колонки и если только 2 колонки) -->
|
||
<div v-if="!isMobile && !isLastColumn(index) && canReorderColumns" class="column-drag-handle">
|
||
<i data-lucide="grip-vertical"></i>
|
||
</div>
|
||
|
||
<!-- Mobile: up/down arrows (скрываем если обе кнопки были бы неактивны) -->
|
||
<div v-else-if="isMobile && !isLastColumn(index) && (index > 0 || index < form.columns.length - 2)" class="column-move-buttons">
|
||
<button
|
||
class="move-btn"
|
||
:disabled="index === 0"
|
||
@click="moveColumnUp(index)"
|
||
title="Вверх"
|
||
>
|
||
<i data-lucide="chevron-up"></i>
|
||
</button>
|
||
<button
|
||
class="move-btn"
|
||
:disabled="index === form.columns.length - 2"
|
||
@click="moveColumnDown(index)"
|
||
title="Вниз"
|
||
>
|
||
<i data-lucide="chevron-down"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<ColorPicker v-model="column.color" />
|
||
|
||
<input
|
||
type="text"
|
||
class="column-name"
|
||
:ref="el => columnInputRefs[index] = el"
|
||
v-model="column.name_columns"
|
||
placeholder="Название колонки"
|
||
/>
|
||
|
||
<!-- Иконка редактирования -->
|
||
<button
|
||
class="column-edit-btn"
|
||
title="Редактировать название"
|
||
@click="focusColumnName(index)"
|
||
>
|
||
<i data-lucide="pencil"></i>
|
||
</button>
|
||
|
||
<!-- Маркер финальной колонки (только отображение) -->
|
||
<span
|
||
v-if="isLastColumn(index)"
|
||
class="column-ready-marker"
|
||
title="Финальная колонка (Готово)"
|
||
>
|
||
<i data-lucide="check-circle"></i>
|
||
</span>
|
||
|
||
<!-- Кнопка удаления: скрыта для последней колонки и если только 2 колонки -->
|
||
<button
|
||
v-if="!isLastColumn(index) && form.columns.length > 2"
|
||
class="column-delete-btn"
|
||
title="Удалить колонку"
|
||
@click="confirmDeleteColumn(index)"
|
||
>
|
||
<i data-lucide="trash-2"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<button class="add-column-btn" @click="addColumn">
|
||
<i data-lucide="plus"></i>
|
||
Добавить колонку
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Отделы -->
|
||
<div class="form-section">
|
||
<label class="form-label">Отделы</label>
|
||
|
||
<div class="departments-list" v-if="form.departments.length > 0">
|
||
<div
|
||
v-for="(department, index) in form.departments"
|
||
:key="department.id || department.tempId"
|
||
class="department-item"
|
||
:class="{
|
||
'is-dragging': !isMobile && deptDragIndex === index,
|
||
'drag-over-top': !isMobile && deptDragOverIndex === index && deptDragPosition === 'top',
|
||
'drag-over-bottom': !isMobile && deptDragOverIndex === index && deptDragPosition === 'bottom'
|
||
}"
|
||
:draggable="!isMobile && canReorderDepartments"
|
||
@dragstart="!isMobile && canReorderDepartments && handleDeptDragStart($event, index)"
|
||
@dragend="!isMobile && handleDeptDragEnd()"
|
||
@dragover.prevent="!isMobile && handleDeptDragOver($event, index)"
|
||
@dragleave="!isMobile && handleDeptDragLeave()"
|
||
@drop.prevent="!isMobile && handleDeptDrop(index)"
|
||
>
|
||
<!-- Desktop: drag handle -->
|
||
<div v-if="!isMobile && canReorderDepartments" class="department-drag-handle">
|
||
<i data-lucide="grip-vertical"></i>
|
||
</div>
|
||
|
||
<!-- Mobile: up/down arrows -->
|
||
<div v-else-if="isMobile && canReorderDepartments" class="department-move-buttons">
|
||
<button
|
||
class="move-btn"
|
||
:disabled="index === 0"
|
||
@click="moveDepartmentUp(index)"
|
||
title="Вверх"
|
||
>
|
||
<i data-lucide="chevron-up"></i>
|
||
</button>
|
||
<button
|
||
class="move-btn"
|
||
:disabled="index === form.departments.length - 1"
|
||
@click="moveDepartmentDown(index)"
|
||
title="Вниз"
|
||
>
|
||
<i data-lucide="chevron-down"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<ColorPicker v-model="department.color" />
|
||
|
||
<input
|
||
type="text"
|
||
class="department-name"
|
||
:ref="el => departmentInputRefs[index] = el"
|
||
v-model="department.name_departments"
|
||
placeholder="Название отдела"
|
||
/>
|
||
|
||
<!-- Иконка редактирования -->
|
||
<button
|
||
class="department-edit-btn"
|
||
title="Редактировать название"
|
||
@click="focusDepartmentName(index)"
|
||
>
|
||
<i data-lucide="pencil"></i>
|
||
</button>
|
||
|
||
<!-- Кнопка удаления -->
|
||
<button
|
||
class="department-delete-btn"
|
||
title="Удалить отдел"
|
||
@click="confirmDeleteDepartment(index)"
|
||
>
|
||
<i data-lucide="trash-2"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<p v-else class="no-departments-hint">
|
||
Отделов пока нет. Добавьте первый отдел.
|
||
</p>
|
||
|
||
<button class="add-department-btn" @click="addDepartment">
|
||
<i data-lucide="plus"></i>
|
||
Добавить отдел
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<template #footer>
|
||
<div class="footer-left">
|
||
<IconButton
|
||
v-if="!isNew"
|
||
icon="trash-2"
|
||
variant="danger"
|
||
title="Удалить проект"
|
||
@click="handleDeleteProject"
|
||
/>
|
||
</div>
|
||
<ActionButtons
|
||
:save-text="isNew ? 'Создать' : 'Сохранить'"
|
||
:loading="isSaving"
|
||
:disabled="!canSave"
|
||
@save="handleSave"
|
||
@cancel="tryClose"
|
||
/>
|
||
</template>
|
||
</SlidePanel>
|
||
|
||
<!-- Диалог удаления проекта -->
|
||
<ConfirmDialog
|
||
:show="showDeleteProjectDialog"
|
||
type="deleteProject"
|
||
:action="confirmDeleteProject"
|
||
@confirm="showDeleteProjectDialog = false"
|
||
@cancel="showDeleteProjectDialog = false"
|
||
/>
|
||
|
||
<!-- Диалог удаления колонки -->
|
||
<ConfirmDialog
|
||
:show="showDeleteColumnDialog"
|
||
type="deleteColumn"
|
||
:message="deleteColumnMessage"
|
||
:action="confirmDeleteColumnAction"
|
||
@confirm="showDeleteColumnDialog = false"
|
||
@cancel="showDeleteColumnDialog = false"
|
||
/>
|
||
|
||
<!-- Диалог удаления отдела -->
|
||
<ConfirmDialog
|
||
:show="showDeleteDepartmentDialog"
|
||
type="deleteDepartment"
|
||
:message="deleteDepartmentMessage"
|
||
:action="confirmDeleteDepartmentAction"
|
||
@confirm="showDeleteDepartmentDialog = false"
|
||
@cancel="showDeleteDepartmentDialog = false"
|
||
/>
|
||
|
||
<!-- Диалог несохранённых изменений -->
|
||
<ConfirmDialog
|
||
:show="showUnsavedDialog"
|
||
type="unsavedChanges"
|
||
@confirm="confirmSave"
|
||
@cancel="showUnsavedDialog = false"
|
||
@discard="confirmDiscard"
|
||
/>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch, nextTick } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import SlidePanel from './ui/SlidePanel.vue'
|
||
import TextInput from './ui/TextInput.vue'
|
||
import ColorPicker from './ui/ColorPicker.vue'
|
||
import IconButton from './ui/IconButton.vue'
|
||
import ActionButtons from './ui/ActionButtons.vue'
|
||
import ConfirmDialog from './ConfirmDialog.vue'
|
||
import { useLucideIcons } from '../composables/useLucideIcons'
|
||
import { useMobile } from '../composables/useMobile'
|
||
import { useProjectsStore } from '../stores/projects'
|
||
import { useToast } from '../composables/useToast'
|
||
|
||
const { refreshIcons } = useLucideIcons()
|
||
const toast = useToast()
|
||
const { isMobile } = useMobile()
|
||
const store = useProjectsStore()
|
||
const router = useRouter()
|
||
|
||
const props = defineProps({
|
||
show: Boolean,
|
||
project: Object // null = создание, object = редактирование
|
||
})
|
||
|
||
const emit = defineEmits(['close', 'saved'])
|
||
|
||
// State
|
||
const isNew = computed(() => !props.project)
|
||
const isSaving = ref(false)
|
||
|
||
// Form data
|
||
const form = ref({
|
||
name: '',
|
||
columns: [],
|
||
departments: []
|
||
})
|
||
|
||
// Initial form for change detection
|
||
const initialForm = ref(null)
|
||
|
||
// Dialogs
|
||
const showDeleteProjectDialog = ref(false)
|
||
const showDeleteColumnDialog = ref(false)
|
||
const showDeleteDepartmentDialog = ref(false)
|
||
const showUnsavedDialog = ref(false)
|
||
const columnToDelete = ref(null)
|
||
const deleteColumnMessage = ref('')
|
||
const departmentToDelete = ref(null)
|
||
const deleteDepartmentMessage = ref('')
|
||
|
||
// Отделы к удалению (ID существующих отделов для удаления при сохранении)
|
||
const departmentsToDelete = ref([])
|
||
|
||
// Drag state for columns
|
||
const dragIndex = ref(null)
|
||
const dragOverIndex = ref(null)
|
||
const dragPosition = ref(null) // 'top' | 'bottom'
|
||
const columnsListRef = ref(null)
|
||
const columnInputRefs = ref([])
|
||
|
||
// Drag state for departments
|
||
const deptDragIndex = ref(null)
|
||
const deptDragOverIndex = ref(null)
|
||
const deptDragPosition = ref(null) // 'top' | 'bottom'
|
||
const departmentInputRefs = ref([])
|
||
|
||
// Temp ID counter for new columns
|
||
let tempIdCounter = 0
|
||
|
||
// Can save
|
||
const canSave = computed(() => {
|
||
return form.value.name?.trim() && form.value.columns.length > 0
|
||
})
|
||
|
||
// Можно ли сортировать колонки (больше 2 колонок = минимум 2 перемещаемые + ready)
|
||
const canReorderColumns = computed(() => {
|
||
return form.value.columns.length > 2
|
||
})
|
||
|
||
// Можно ли сортировать отделы (больше 1 отдела)
|
||
const canReorderDepartments = computed(() => {
|
||
return form.value.departments.length > 1
|
||
})
|
||
|
||
// Has changes
|
||
const hasChanges = computed(() => {
|
||
if (!initialForm.value) return false
|
||
return JSON.stringify(form.value) !== JSON.stringify(initialForm.value)
|
||
})
|
||
|
||
// ==================== COLUMNS MANAGEMENT ====================
|
||
|
||
// Фокус на поле названия колонки
|
||
const focusColumnName = (index) => {
|
||
const input = columnInputRefs.value[index]
|
||
if (input) {
|
||
input.focus()
|
||
// Ставим курсор в конец текста
|
||
const len = input.value.length
|
||
input.setSelectionRange(len, len)
|
||
}
|
||
}
|
||
|
||
// Проверка является ли колонка последней (финальной)
|
||
const isLastColumn = (index) => {
|
||
return index === form.value.columns.length - 1
|
||
}
|
||
|
||
// Для совместимости со стилями (is-ready класс)
|
||
const isReadyColumn = (column) => {
|
||
const index = form.value.columns.findIndex(c =>
|
||
(c.id && c.id === column.id) || (c.tempId && c.tempId === column.tempId)
|
||
)
|
||
return isLastColumn(index)
|
||
}
|
||
|
||
// Цвета для новых колонок
|
||
const columnColors = [
|
||
'#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16',
|
||
'#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9',
|
||
'#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef',
|
||
'#ec4899', '#f43f5e'
|
||
]
|
||
|
||
const getRandomColor = () => {
|
||
// Получаем цвета уже используемых колонок
|
||
const usedColors = form.value.columns.map(c => c.color)
|
||
// Фильтруем доступные цвета
|
||
const availableColors = columnColors.filter(c => !usedColors.includes(c))
|
||
// Если все цвета использованы — берём из полного списка
|
||
const colorPool = availableColors.length > 0 ? availableColors : columnColors
|
||
return colorPool[Math.floor(Math.random() * colorPool.length)]
|
||
}
|
||
|
||
const addColumn = () => {
|
||
// Находим индекс финальной колонки
|
||
const readyIndex = form.value.columns.findIndex(col => isReadyColumn(col))
|
||
|
||
const newColumn = {
|
||
tempId: `new-${++tempIdCounter}`,
|
||
name_columns: '',
|
||
color: getRandomColor(),
|
||
id_order: 0
|
||
}
|
||
|
||
// Вставляем перед финальной колонкой, или в конец если финальная не найдена
|
||
if (readyIndex !== -1) {
|
||
form.value.columns.splice(readyIndex, 0, newColumn)
|
||
} else {
|
||
form.value.columns.push(newColumn)
|
||
}
|
||
|
||
// Обновляем id_order у всех колонок
|
||
form.value.columns.forEach((col, idx) => {
|
||
col.id_order = idx + 1
|
||
})
|
||
|
||
nextTick(refreshIcons)
|
||
}
|
||
|
||
const confirmDeleteColumn = async (index) => {
|
||
const column = form.value.columns[index]
|
||
columnToDelete.value = index
|
||
|
||
// Для существующих колонок проверяем количество задач
|
||
if (column.id) {
|
||
const count = await store.getColumnTasksCount(column.id)
|
||
if (count > 0) {
|
||
deleteColumnMessage.value = `В колонке ${count} задач.<br>Все они будут удалены.`
|
||
} else {
|
||
deleteColumnMessage.value = 'Колонка будет удалена.'
|
||
}
|
||
} else {
|
||
deleteColumnMessage.value = 'Колонка будет удалена.'
|
||
}
|
||
|
||
showDeleteColumnDialog.value = true
|
||
}
|
||
|
||
const confirmDeleteColumnAction = async () => {
|
||
const index = columnToDelete.value
|
||
const column = form.value.columns[index]
|
||
|
||
// Если колонка уже существует на сервере — удаляем через API
|
||
if (column.id) {
|
||
const result = await store.deleteColumn(column.id)
|
||
if (!result.success) {
|
||
throw new Error(result.errors?.column || 'Ошибка удаления')
|
||
}
|
||
}
|
||
|
||
// Удаляем из формы
|
||
form.value.columns.splice(index, 1)
|
||
|
||
columnToDelete.value = null
|
||
}
|
||
|
||
// ==================== DEPARTMENTS MANAGEMENT ====================
|
||
|
||
// Цвета для новых отделов
|
||
const departmentColors = [
|
||
'#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16',
|
||
'#22c55e', '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9',
|
||
'#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef',
|
||
'#ec4899', '#f43f5e'
|
||
]
|
||
|
||
const getRandomDepartmentColor = () => {
|
||
// Получаем цвета уже используемых отделов
|
||
const usedColors = form.value.departments.map(d => d.color)
|
||
// Фильтруем доступные цвета
|
||
const availableColors = departmentColors.filter(c => !usedColors.includes(c))
|
||
// Если все цвета использованы — берём из полного списка
|
||
const colorPool = availableColors.length > 0 ? availableColors : departmentColors
|
||
return colorPool[Math.floor(Math.random() * colorPool.length)]
|
||
}
|
||
|
||
// Фокус на поле названия отдела
|
||
const focusDepartmentName = (index) => {
|
||
const input = departmentInputRefs.value[index]
|
||
if (input) {
|
||
input.focus()
|
||
const len = input.value.length
|
||
input.setSelectionRange(len, len)
|
||
}
|
||
}
|
||
|
||
const addDepartment = () => {
|
||
const newDepartment = {
|
||
tempId: `new-dept-${++tempIdCounter}`,
|
||
name_departments: '',
|
||
color: getRandomDepartmentColor(),
|
||
order_id: form.value.departments.length + 1
|
||
}
|
||
|
||
form.value.departments.push(newDepartment)
|
||
nextTick(refreshIcons)
|
||
}
|
||
|
||
const confirmDeleteDepartment = async (index) => {
|
||
const department = form.value.departments[index]
|
||
|
||
// Для новых отделов (ещё не на сервере) — удаляем сразу без диалога
|
||
if (department.tempId && !department.id) {
|
||
form.value.departments.splice(index, 1)
|
||
nextTick(refreshIcons)
|
||
return
|
||
}
|
||
|
||
// Для существующих отделов — показываем диалог с предупреждением
|
||
departmentToDelete.value = index
|
||
|
||
const count = await store.getDepartmentTasksCount(department.id)
|
||
if (count > 0) {
|
||
deleteDepartmentMessage.value = `В отделе ${count} задач.<br>Задачи не удалятся, но потеряют привязку к отделу.`
|
||
} else {
|
||
deleteDepartmentMessage.value = 'Отдел будет удалён.'
|
||
}
|
||
|
||
showDeleteDepartmentDialog.value = true
|
||
}
|
||
|
||
const confirmDeleteDepartmentAction = async () => {
|
||
const index = departmentToDelete.value
|
||
const department = form.value.departments[index]
|
||
|
||
// Если отдел существует на сервере — добавляем в список для удаления при сохранении
|
||
if (department.id) {
|
||
departmentsToDelete.value.push(department.id)
|
||
}
|
||
|
||
// Удаляем из формы (локально)
|
||
form.value.departments.splice(index, 1)
|
||
|
||
departmentToDelete.value = null
|
||
}
|
||
|
||
// ==================== MOBILE: MOVE DEPARTMENTS ====================
|
||
|
||
const moveDepartmentUp = (index) => {
|
||
if (index <= 0) return
|
||
|
||
const departments = form.value.departments
|
||
;[departments[index - 1], departments[index]] = [departments[index], departments[index - 1]]
|
||
|
||
// Update order_id
|
||
departments.forEach((dept, idx) => {
|
||
dept.order_id = idx + 1
|
||
})
|
||
|
||
nextTick(refreshIcons)
|
||
}
|
||
|
||
const moveDepartmentDown = (index) => {
|
||
const lastIndex = form.value.departments.length - 1
|
||
if (index >= lastIndex) return
|
||
|
||
const departments = form.value.departments
|
||
;[departments[index], departments[index + 1]] = [departments[index + 1], departments[index]]
|
||
|
||
// Update order_id
|
||
departments.forEach((dept, idx) => {
|
||
dept.order_id = idx + 1
|
||
})
|
||
|
||
nextTick(refreshIcons)
|
||
}
|
||
|
||
// ==================== DRAG AND DROP DEPARTMENTS (Desktop) ====================
|
||
|
||
const handleDeptDragStart = (e, index) => {
|
||
deptDragIndex.value = index
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
e.dataTransfer.setData('text/plain', `dept-${index}`)
|
||
}
|
||
|
||
const handleDeptDragEnd = () => {
|
||
deptDragIndex.value = null
|
||
deptDragOverIndex.value = null
|
||
deptDragPosition.value = null
|
||
}
|
||
|
||
const handleDeptDragOver = (e, index) => {
|
||
if (deptDragIndex.value === null || deptDragIndex.value === index) return
|
||
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
const midY = rect.top + rect.height / 2
|
||
|
||
deptDragOverIndex.value = index
|
||
deptDragPosition.value = e.clientY < midY ? 'top' : 'bottom'
|
||
}
|
||
|
||
const handleDeptDragLeave = () => {
|
||
// Don't clear immediately to avoid flickering
|
||
}
|
||
|
||
const handleDeptDrop = (targetIndex) => {
|
||
if (deptDragIndex.value === null || deptDragIndex.value === targetIndex) {
|
||
handleDeptDragEnd()
|
||
return
|
||
}
|
||
|
||
const sourceIndex = deptDragIndex.value
|
||
let insertIndex = targetIndex
|
||
|
||
// Adjust insert index based on drag position
|
||
if (deptDragPosition.value === 'bottom') {
|
||
insertIndex = targetIndex + 1
|
||
}
|
||
|
||
// If moving down, adjust for removal
|
||
if (sourceIndex < insertIndex) {
|
||
insertIndex--
|
||
}
|
||
|
||
// Reorder array
|
||
const [movedDept] = form.value.departments.splice(sourceIndex, 1)
|
||
form.value.departments.splice(insertIndex, 0, movedDept)
|
||
|
||
// Update order_id
|
||
form.value.departments.forEach((dept, idx) => {
|
||
dept.order_id = idx + 1
|
||
})
|
||
|
||
handleDeptDragEnd()
|
||
}
|
||
|
||
// ==================== MOBILE: MOVE COLUMNS ====================
|
||
|
||
const moveColumnUp = async (index) => {
|
||
if (index <= 0) return
|
||
|
||
const columns = form.value.columns
|
||
;[columns[index - 1], columns[index]] = [columns[index], columns[index - 1]]
|
||
|
||
// Update id_order
|
||
columns.forEach((col, idx) => {
|
||
col.id_order = idx + 1
|
||
})
|
||
|
||
// Save to server if editing
|
||
if (!isNew.value) {
|
||
const ids = columns.filter(c => c.id).map(c => c.id)
|
||
if (ids.length > 0) {
|
||
await store.reorderColumns(ids)
|
||
}
|
||
}
|
||
|
||
nextTick(refreshIcons)
|
||
}
|
||
|
||
const moveColumnDown = async (index) => {
|
||
// Нельзя перемещать последнюю колонку и нельзя менять местами с последней
|
||
const lastIndex = form.value.columns.length - 1
|
||
if (index >= lastIndex - 1) return
|
||
|
||
const columns = form.value.columns
|
||
;[columns[index], columns[index + 1]] = [columns[index + 1], columns[index]]
|
||
|
||
// Update id_order
|
||
columns.forEach((col, idx) => {
|
||
col.id_order = idx + 1
|
||
})
|
||
|
||
// Save to server if editing
|
||
if (!isNew.value) {
|
||
const ids = columns.filter(c => c.id).map(c => c.id)
|
||
if (ids.length > 0) {
|
||
await store.reorderColumns(ids)
|
||
}
|
||
}
|
||
|
||
nextTick(refreshIcons)
|
||
}
|
||
|
||
// ==================== DRAG AND DROP COLUMNS (Desktop) ====================
|
||
|
||
const handleColumnDragStart = (e, index) => {
|
||
dragIndex.value = index
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
e.dataTransfer.setData('text/plain', index.toString())
|
||
}
|
||
|
||
const handleColumnDragEnd = () => {
|
||
dragIndex.value = null
|
||
dragOverIndex.value = null
|
||
dragPosition.value = null
|
||
}
|
||
|
||
const handleColumnDragOver = (e, index) => {
|
||
if (dragIndex.value === null || dragIndex.value === index) return
|
||
|
||
// Нельзя перетаскивать НА последнюю колонку (она зафиксирована)
|
||
const lastIndex = form.value.columns.length - 1
|
||
if (index === lastIndex) return
|
||
|
||
const rect = e.currentTarget.getBoundingClientRect()
|
||
const midY = rect.top + rect.height / 2
|
||
|
||
dragOverIndex.value = index
|
||
|
||
// Для предпоследней колонки: нельзя drop "bottom" (это поставит после последней)
|
||
if (index === lastIndex - 1) {
|
||
dragPosition.value = 'top'
|
||
} else {
|
||
dragPosition.value = e.clientY < midY ? 'top' : 'bottom'
|
||
}
|
||
}
|
||
|
||
const handleColumnDragLeave = () => {
|
||
// Don't clear immediately to avoid flickering
|
||
}
|
||
|
||
const handleColumnDrop = async (targetIndex) => {
|
||
if (dragIndex.value === null || dragIndex.value === targetIndex) {
|
||
handleColumnDragEnd()
|
||
return
|
||
}
|
||
|
||
const lastIndex = form.value.columns.length - 1
|
||
|
||
// Нельзя drop на последнюю колонку
|
||
if (targetIndex === lastIndex) {
|
||
handleColumnDragEnd()
|
||
return
|
||
}
|
||
|
||
const sourceIndex = dragIndex.value
|
||
let insertIndex = targetIndex
|
||
|
||
// Adjust insert index based on drag position
|
||
if (dragPosition.value === 'bottom') {
|
||
insertIndex = targetIndex + 1
|
||
}
|
||
|
||
// If moving down, adjust for removal
|
||
if (sourceIndex < insertIndex) {
|
||
insertIndex--
|
||
}
|
||
|
||
// Не даём вставить на позицию последней колонки (перед ready)
|
||
const maxInsertIndex = lastIndex - 1
|
||
if (insertIndex > maxInsertIndex) {
|
||
insertIndex = maxInsertIndex
|
||
}
|
||
|
||
// Reorder array
|
||
const [movedColumn] = form.value.columns.splice(sourceIndex, 1)
|
||
form.value.columns.splice(insertIndex, 0, movedColumn)
|
||
|
||
// Update id_order
|
||
form.value.columns.forEach((col, idx) => {
|
||
col.id_order = idx + 1
|
||
})
|
||
|
||
// If editing existing project — save order to server
|
||
if (!isNew.value) {
|
||
const ids = form.value.columns.filter(c => c.id).map(c => c.id)
|
||
if (ids.length > 0) {
|
||
await store.reorderColumns(ids)
|
||
}
|
||
}
|
||
|
||
handleColumnDragEnd()
|
||
}
|
||
|
||
// ==================== SAVE / CLOSE ====================
|
||
|
||
const handleSave = async () => {
|
||
if (!canSave.value || isSaving.value) return
|
||
|
||
isSaving.value = true
|
||
|
||
try {
|
||
if (isNew.value) {
|
||
// Проверяем что у всех колонок есть имена
|
||
const emptyColumnNew = form.value.columns.find(c => !c.name_columns?.trim())
|
||
if (emptyColumnNew) {
|
||
toast.error('Укажите название для всех колонок')
|
||
return
|
||
}
|
||
|
||
// Проверяем что у всех отделов есть имена (для нового проекта тоже)
|
||
const emptyDeptNew = form.value.departments.find(d => !d.name_departments?.trim())
|
||
if (emptyDeptNew) {
|
||
toast.error('Укажите название для всех отделов')
|
||
return
|
||
}
|
||
|
||
// Создание проекта
|
||
const result = await store.createProject(form.value.name)
|
||
|
||
if (result.success) {
|
||
const newProjectId = result.id
|
||
|
||
// Создаём колонки для нового проекта
|
||
const createdColumnIds = []
|
||
for (const column of form.value.columns) {
|
||
if (column.tempId) {
|
||
const colResult = await store.addColumn(column.name_columns, column.color)
|
||
if (colResult.success) {
|
||
createdColumnIds.push(colResult.id)
|
||
} else {
|
||
toast.error('Ошибка создания колонки')
|
||
}
|
||
}
|
||
}
|
||
|
||
// Устанавливаем последнюю колонку как финальную (id_ready)
|
||
if (createdColumnIds.length > 0) {
|
||
const lastColumnId = createdColumnIds[createdColumnIds.length - 1]
|
||
await store.reorderColumns(createdColumnIds) // Это автоматически установит id_ready
|
||
}
|
||
|
||
// Создаём отделы для нового проекта
|
||
for (const department of form.value.departments) {
|
||
if (department.tempId) {
|
||
const deptResult = await store.addDepartmentToProject(
|
||
newProjectId,
|
||
department.name_departments,
|
||
department.color
|
||
)
|
||
if (!deptResult.success) {
|
||
toast.error(deptResult.errors?.name || 'Ошибка создания отдела')
|
||
}
|
||
}
|
||
}
|
||
|
||
toast.success('Проект создан')
|
||
emit('saved', newProjectId)
|
||
emit('close')
|
||
} else {
|
||
toast.error('Ошибка создания проекта')
|
||
}
|
||
} else {
|
||
// Редактирование проекта
|
||
// Проверяем что у всех колонок есть имена
|
||
const emptyColumn = form.value.columns.find(c => !c.name_columns?.trim())
|
||
if (emptyColumn) {
|
||
toast.error('Укажите название для всех колонок')
|
||
return
|
||
}
|
||
|
||
// Обновляем название если изменилось
|
||
if (form.value.name !== initialForm.value.name) {
|
||
await store.updateProject(props.project.id, form.value.name)
|
||
}
|
||
|
||
// Обрабатываем новые колонки
|
||
for (const column of form.value.columns) {
|
||
if (column.tempId && !column.id) {
|
||
// Новая колонка
|
||
const result = await store.addColumn(column.name_columns, column.color)
|
||
if (result.success) {
|
||
column.id = result.id
|
||
delete column.tempId
|
||
}
|
||
} else if (column.id) {
|
||
// Проверяем изменения в существующей колонке
|
||
const original = initialForm.value.columns.find(c => c.id === column.id)
|
||
if (original) {
|
||
const nameChanged = column.name_columns !== original.name_columns
|
||
const colorChanged = column.color !== original.color
|
||
if (nameChanged || colorChanged) {
|
||
await store.updateColumn(column.id, column.name_columns, column.color)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Сохраняем порядок колонок
|
||
const ids = form.value.columns.filter(c => c.id).map(c => c.id)
|
||
if (ids.length > 0) {
|
||
await store.reorderColumns(ids)
|
||
}
|
||
|
||
// Удаляем отделы, помеченные на удаление
|
||
for (const deptId of departmentsToDelete.value) {
|
||
const result = await store.deleteDepartment(deptId)
|
||
if (!result.success) {
|
||
toast.error(result.errors?.department || result.errors?.access || 'Ошибка удаления отдела')
|
||
return
|
||
}
|
||
}
|
||
departmentsToDelete.value = []
|
||
|
||
// Проверяем что у всех отделов есть имена
|
||
const emptyDepartment = form.value.departments.find(d => !d.name_departments?.trim())
|
||
if (emptyDepartment) {
|
||
toast.error('Укажите название для всех отделов')
|
||
return
|
||
}
|
||
|
||
// Обрабатываем отделы
|
||
for (const department of form.value.departments) {
|
||
if (department.tempId && !department.id) {
|
||
// Новый отдел
|
||
const result = await store.addDepartment(department.name_departments, department.color)
|
||
if (result.success) {
|
||
department.id = result.id
|
||
delete department.tempId
|
||
} else {
|
||
toast.error(result.errors?.name || result.errors?.access || 'Ошибка создания отдела')
|
||
return
|
||
}
|
||
} else if (department.id) {
|
||
// Проверяем изменения в существующем отделе
|
||
const original = initialForm.value.departments.find(d => d.id === department.id)
|
||
if (original) {
|
||
const nameChanged = department.name_departments !== original.name_departments
|
||
const colorChanged = department.color !== original.color
|
||
if (nameChanged || colorChanged) {
|
||
const result = await store.updateDepartment(department.id, department.name_departments, department.color)
|
||
if (!result.success) {
|
||
toast.error(result.errors?.name || result.errors?.access || 'Ошибка обновления отдела')
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Сохраняем порядок отделов
|
||
const deptIds = form.value.departments.filter(d => d.id).map(d => d.id)
|
||
if (deptIds.length > 0) {
|
||
await store.reorderDepartments(deptIds)
|
||
}
|
||
|
||
toast.success('Изменения сохранены')
|
||
emit('saved', props.project.id)
|
||
emit('close')
|
||
}
|
||
} finally {
|
||
isSaving.value = false
|
||
}
|
||
}
|
||
|
||
const tryClose = () => {
|
||
if (hasChanges.value) {
|
||
showUnsavedDialog.value = true
|
||
} else {
|
||
emit('close')
|
||
}
|
||
}
|
||
|
||
const confirmSave = () => {
|
||
showUnsavedDialog.value = false
|
||
handleSave()
|
||
}
|
||
|
||
const confirmDiscard = () => {
|
||
showUnsavedDialog.value = false
|
||
emit('close')
|
||
}
|
||
|
||
// ==================== DELETE PROJECT ====================
|
||
|
||
const handleDeleteProject = () => {
|
||
showDeleteProjectDialog.value = true
|
||
}
|
||
|
||
const confirmDeleteProject = async () => {
|
||
if (!props.project?.id) {
|
||
throw new Error('Проект не выбран')
|
||
}
|
||
|
||
const result = await store.deleteProject(props.project.id)
|
||
if (!result.success) {
|
||
toast.error(result.errors?.access || 'Ошибка удаления')
|
||
throw new Error(result.errors?.access || 'Ошибка удаления')
|
||
}
|
||
|
||
toast.success('Проект удалён')
|
||
emit('close')
|
||
|
||
// Если после удаления нет проектов — переходим на страницу без проектов
|
||
if (store.projects.length === 0) {
|
||
router.push('/no-projects')
|
||
}
|
||
}
|
||
|
||
// ==================== WATCH ====================
|
||
|
||
watch(() => props.show, async (newVal) => {
|
||
if (newVal) {
|
||
isSaving.value = false
|
||
tempIdCounter = 0
|
||
departmentsToDelete.value = []
|
||
|
||
if (props.project) {
|
||
// Редактирование — загружаем данные
|
||
form.value = {
|
||
name: props.project.name,
|
||
columns: JSON.parse(JSON.stringify(store.columns)),
|
||
departments: JSON.parse(JSON.stringify(store.departments))
|
||
}
|
||
} else {
|
||
// Создание — дефолтные значения (последняя колонка = финальная)
|
||
form.value = {
|
||
name: '',
|
||
columns: [
|
||
{ tempId: 'new-1', name_columns: 'К выполнению', color: '#6366f1', id_order: 1 },
|
||
{ tempId: 'new-2', name_columns: 'Готово', color: '#22c55e', id_order: 2 }
|
||
],
|
||
departments: [
|
||
{ tempId: 'new-dept-1', name_departments: 'Отдел разработки', color: '#3b82f6', order_id: 1 }
|
||
]
|
||
}
|
||
tempIdCounter = 2
|
||
}
|
||
|
||
// Сохраняем начальное состояние
|
||
await nextTick()
|
||
initialForm.value = JSON.parse(JSON.stringify(form.value))
|
||
refreshIcons()
|
||
}
|
||
})
|
||
|
||
// Refresh icons when columns or departments change
|
||
watch(() => form.value.columns.length, () => {
|
||
nextTick(refreshIcons)
|
||
})
|
||
|
||
watch(() => form.value.departments.length, () => {
|
||
nextTick(refreshIcons)
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.form-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
|
||
.form-label {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
/* ==================== COLUMNS LIST ==================== */
|
||
|
||
.columns-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.column-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||
border-radius: 8px;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.column-item:hover {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-color: rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.column-item.is-ready {
|
||
border-color: var(--green);
|
||
background: rgba(34, 197, 94, 0.05);
|
||
}
|
||
|
||
.column-item.is-dragging {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.column-item.drag-over-top {
|
||
border-top: 2px solid var(--accent);
|
||
padding-top: 6px;
|
||
}
|
||
|
||
.column-item.drag-over-bottom {
|
||
border-bottom: 2px solid var(--accent);
|
||
padding-bottom: 6px;
|
||
}
|
||
|
||
.column-drag-handle {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 24px;
|
||
height: 24px;
|
||
color: var(--text-muted);
|
||
cursor: grab;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.column-drag-handle:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.column-drag-handle i {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
/* Mobile: up/down buttons */
|
||
.column-move-buttons {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.move-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 24px;
|
||
height: 18px;
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border: none;
|
||
border-radius: 4px;
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.move-btn i {
|
||
width: 14px;
|
||
height: 14px;
|
||
}
|
||
|
||
.move-btn:not(:disabled):hover,
|
||
.move-btn:not(:disabled):active {
|
||
background: rgba(255, 255, 255, 0.12);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.move-btn:disabled {
|
||
opacity: 0.3;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.column-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text-primary);
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
outline: none;
|
||
padding: 8px;
|
||
}
|
||
|
||
.column-name::placeholder {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.column-name:focus {
|
||
background: rgba(255, 255, 255, 0.04);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.column-edit-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
background: none;
|
||
border: none;
|
||
border-radius: 6px;
|
||
color: var(--text-muted);
|
||
opacity: 0.5;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.column-edit-btn i {
|
||
width: 14px;
|
||
height: 14px;
|
||
}
|
||
|
||
.column-edit-btn:hover {
|
||
opacity: 1;
|
||
background: rgba(255, 255, 255, 0.06);
|
||
color: var(--accent);
|
||
}
|
||
|
||
/* Маркер финальной колонки (не кликабельный) */
|
||
.column-ready-marker {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
color: var(--green);
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.column-ready-marker i {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
.column-delete-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
background: none;
|
||
border: none;
|
||
border-radius: 6px;
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.column-delete-btn i {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
.column-delete-btn:hover {
|
||
background: rgba(239, 68, 68, 0.1);
|
||
color: var(--red);
|
||
}
|
||
|
||
/* ==================== ADD COLUMN BUTTON ==================== */
|
||
|
||
.add-column-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
padding: 12px;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||
border-radius: 8px;
|
||
color: var(--text-muted);
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.add-column-btn:hover {
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.add-column-btn i {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
/* ==================== DEPARTMENTS LIST ==================== */
|
||
|
||
.departments-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
|
||
.department-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||
border-radius: 8px;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.department-item:hover {
|
||
background: rgba(255, 255, 255, 0.05);
|
||
border-color: rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.department-item.is-dragging {
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.department-item.drag-over-top {
|
||
border-top: 2px solid var(--accent);
|
||
padding-top: 6px;
|
||
}
|
||
|
||
.department-item.drag-over-bottom {
|
||
border-bottom: 2px solid var(--accent);
|
||
padding-bottom: 6px;
|
||
}
|
||
|
||
.department-drag-handle {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 24px;
|
||
height: 24px;
|
||
color: var(--text-muted);
|
||
cursor: grab;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.department-drag-handle:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.department-drag-handle i {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
/* Mobile: up/down buttons for departments */
|
||
.department-move-buttons {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.department-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text-primary);
|
||
font-family: inherit;
|
||
font-size: 14px;
|
||
outline: none;
|
||
padding: 8px;
|
||
}
|
||
|
||
.department-name::placeholder {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.department-name:focus {
|
||
background: rgba(255, 255, 255, 0.04);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.department-edit-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
background: none;
|
||
border: none;
|
||
border-radius: 6px;
|
||
color: var(--text-muted);
|
||
opacity: 0.5;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.department-edit-btn i {
|
||
width: 14px;
|
||
height: 14px;
|
||
}
|
||
|
||
.department-edit-btn:hover {
|
||
opacity: 1;
|
||
background: rgba(255, 255, 255, 0.06);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.department-delete-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
background: none;
|
||
border: none;
|
||
border-radius: 6px;
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.department-delete-btn i {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
.department-delete-btn:hover {
|
||
background: rgba(239, 68, 68, 0.1);
|
||
color: var(--red);
|
||
}
|
||
|
||
.no-departments-hint {
|
||
color: var(--text-muted);
|
||
font-size: 13px;
|
||
text-align: center;
|
||
padding: 12px;
|
||
}
|
||
|
||
/* ==================== ADD DEPARTMENT BUTTON ==================== */
|
||
|
||
.add-department-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
padding: 12px;
|
||
background: rgba(255, 255, 255, 0.03);
|
||
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||
border-radius: 8px;
|
||
color: var(--text-muted);
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.add-department-btn:hover {
|
||
background: rgba(255, 255, 255, 0.06);
|
||
border-color: var(--accent);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.add-department-btn i {
|
||
width: 16px;
|
||
height: 16px;
|
||
}
|
||
|
||
/* ==================== FOOTER ==================== */
|
||
|
||
.footer-left {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
</style>
|