1
0

Важный фикс

Забыл добавить управление отделами :)
This commit is contained in:
2026-01-18 20:45:17 +07:00
parent 190b4d0a5e
commit e8a4480747
7 changed files with 773 additions and 9 deletions

View File

@@ -142,6 +142,42 @@ export const projectsApi = {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'set_ready_column', project_id, column_id })
}),
// ==================== ОТДЕЛЫ ====================
// Добавление отдела
addDepartment: (project_id, name, color) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'add_department', project_id, name, color })
}),
// Обновление отдела
updateDepartment: (id, name, color) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update_department', id, name, color })
}),
// Получение количества задач в отделе
getDepartmentTasksCount: (id) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'get_department_tasks_count', id })
}),
// Удаление отдела
deleteDepartment: (id) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete_department', id })
}),
// Обновление порядка отделов
updateDepartmentsOrder: (project_id, ids) => request('/api/project', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'update_departments_order', project_id, ids })
})
}

View File

@@ -109,6 +109,92 @@
Добавить колонку
</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>
@@ -150,6 +236,16 @@
@cancel="showDeleteColumnDialog = false"
/>
<!-- Диалог удаления отдела -->
<ConfirmDialog
:show="showDeleteDepartmentDialog"
type="deleteDepartment"
:message="deleteDepartmentMessage"
:action="confirmDeleteDepartmentAction"
@confirm="showDeleteDepartmentDialog = false"
@cancel="showDeleteDepartmentDialog = false"
/>
<!-- Диалог несохранённых изменений -->
<ConfirmDialog
:show="showUnsavedDialog"
@@ -194,7 +290,8 @@ const isSaving = ref(false)
// Form data
const form = ref({
name: '',
columns: []
columns: [],
departments: []
})
// Initial form for change detection
@@ -203,17 +300,26 @@ 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('')
// Drag state
// 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
@@ -227,6 +333,11 @@ 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
@@ -340,6 +451,175 @@ const confirmDeleteColumnAction = async () => {
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]
departmentToDelete.value = index
// Для существующих отделов проверяем количество задач
if (department.id) {
const count = await store.getDepartmentTasksCount(department.id)
if (count > 0) {
deleteDepartmentMessage.value = `В отделе ${count} задач.<br>Задачи не удалятся, но потеряют привязку к отделу.`
} else {
deleteDepartmentMessage.value = 'Отдел будет удалён.'
}
} else {
deleteDepartmentMessage.value = 'Отдел будет удалён.'
}
showDeleteDepartmentDialog.value = true
}
const confirmDeleteDepartmentAction = async () => {
const index = departmentToDelete.value
const department = form.value.departments[index]
// Если отдел уже существует на сервере — удаляем через API
if (department.id) {
const result = await store.deleteDepartment(department.id)
if (!result.success) {
throw new Error(result.errors?.department || 'Ошибка удаления')
}
}
// Удаляем из формы
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) => {
@@ -536,6 +816,34 @@ const handleSave = async () => {
await store.reorderColumns(ids)
}
// Обрабатываем отделы
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 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) {
await store.updateDepartment(department.id, department.name_departments, department.color)
}
}
}
}
// Сохраняем порядок отделов
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')
@@ -600,7 +908,8 @@ watch(() => props.show, async (newVal) => {
// Редактирование — загружаем данные
form.value = {
name: props.project.name,
columns: JSON.parse(JSON.stringify(store.columns))
columns: JSON.parse(JSON.stringify(store.columns)),
departments: JSON.parse(JSON.stringify(store.departments))
}
} else {
// Создание — дефолтные значения (последняя колонка = финальная)
@@ -609,6 +918,9 @@ watch(() => props.show, async (newVal) => {
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
@@ -621,10 +933,14 @@ watch(() => props.show, async (newVal) => {
}
})
// Refresh icons when columns change
// Refresh icons when columns or departments change
watch(() => form.value.columns.length, () => {
nextTick(refreshIcons)
})
watch(() => form.value.departments.length, () => {
nextTick(refreshIcons)
})
</script>
<style scoped>
@@ -862,6 +1178,182 @@ watch(() => form.value.columns.length, () => {
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 {

View File

@@ -98,5 +98,13 @@ export const DIALOGS = {
message: 'Вы будете перенаправлены<br>на страницу входа.',
confirmText: 'Выйти',
variant: 'warning'
},
// Удаление отдела
deleteDepartment: {
title: 'Удалить отдел?',
message: '', // Будет задан динамически
confirmText: 'Удалить',
variant: 'danger'
}
}

View File

@@ -596,6 +596,71 @@ export const useProjectsStore = defineStore('projects', () => {
return result
}
// ==================== CRUD ОТДЕЛОВ ====================
// Добавление отдела
const addDepartment = async (name, color = '#6366f1') => {
if (!currentProjectId.value) return { success: false }
const result = await projectsApi.addDepartment(currentProjectId.value, name, color)
if (result.success) {
departments.value.push(result.department)
}
return result
}
// Обновление отдела
const updateDepartment = async (id, name, color) => {
const result = await projectsApi.updateDepartment(id, name, color)
if (result.success) {
const department = departments.value.find(d => d.id === id)
if (department) {
if (name !== null && name !== undefined) department.name_departments = name
if (color !== null && color !== undefined) department.color = color
}
}
return result
}
// Получение количества задач в отделе
const getDepartmentTasksCount = async (id) => {
const result = await projectsApi.getDepartmentTasksCount(id)
return result.success ? result.count : 0
}
// Удаление отдела
const deleteDepartment = async (id) => {
const result = await projectsApi.deleteDepartment(id)
if (result.success) {
const index = departments.value.findIndex(d => d.id === id)
if (index !== -1) {
departments.value.splice(index, 1)
}
// Обнуляем id_department у карточек этого отдела
cards.value.forEach(card => {
if (card.id_department === id) {
card.id_department = null
}
})
}
return result
}
// Обновление порядка отделов
const reorderDepartments = async (ids) => {
if (!currentProjectId.value) return
// Оптимистичное обновление
const reordered = ids.map((id, index) => {
const department = departments.value.find(d => d.id === id)
return { ...department, order_id: index + 1 }
})
departments.value = reordered
// Отправляем на сервер
await projectsApi.updateDepartmentsOrder(currentProjectId.value, ids)
}
return {
// Состояние
projects,
@@ -646,6 +711,12 @@ export const useProjectsStore = defineStore('projects', () => {
getColumnTasksCount,
deleteColumn,
reorderColumns,
setReadyColumn
setReadyColumn,
// CRUD отделов
addDepartment,
updateDepartment,
getDepartmentTasksCount,
deleteDepartment,
reorderDepartments
}
})

View File

@@ -375,7 +375,9 @@ const handleLogin = async () => {
const data = await authApi.login(login.value, password.value)
if (data.success) {
setAuthCache(true)
// Получаем данные пользователя для кэша
const checkResult = await authApi.check()
setAuthCache(true, checkResult.user || null)
showSuccess.value = true
await nextTick()
refreshIcons()
@@ -421,7 +423,9 @@ const handleRegister = async () => {
const loginResult = await authApi.login(regUsername.value.trim(), regPassword.value)
if (loginResult.success) {
setAuthCache(true)
// Получаем данные пользователя для кэша
const checkResult = await authApi.check()
setAuthCache(true, checkResult.user || null)
showSuccess.value = true
await nextTick()
refreshIcons()