1
0
Files
TaskBoard/front_vue/src/components/ProjectPanel.vue
2026-01-21 12:46:43 +07:00

1446 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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