218 lines
6.1 KiB
Vue
218 lines
6.1 KiB
Vue
<template>
|
||
<div class="board">
|
||
<div class="columns">
|
||
<Column
|
||
v-for="column in filteredColumns"
|
||
:key="column.id"
|
||
:column="column"
|
||
:departments="departments"
|
||
:labels="labels"
|
||
@drop-card="handleDropCard"
|
||
@open-task="(card) => emit('open-task', { card, columnId: column.id })"
|
||
@create-task="emit('create-task', column.id)"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUpdated, watch } from 'vue'
|
||
import Column from './Column.vue'
|
||
|
||
const props = defineProps({
|
||
activeDepartment: Number,
|
||
departments: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
labels: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
columns: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
cards: {
|
||
type: Array,
|
||
default: () => []
|
||
}
|
||
})
|
||
|
||
const emit = defineEmits(['stats-updated', 'open-task', 'create-task'])
|
||
|
||
const refreshIcons = () => {
|
||
if (window.lucide) {
|
||
window.lucide.createIcons()
|
||
}
|
||
}
|
||
|
||
onMounted(refreshIcons)
|
||
onUpdated(refreshIcons)
|
||
|
||
// Локальная копия карточек для optimistic UI
|
||
const localCards = ref([])
|
||
|
||
// Синхронизируем с props при загрузке данных
|
||
watch(() => props.cards, (newCards) => {
|
||
if (newCards.length > 0 && localCards.value.length === 0) {
|
||
// Первая загрузка - копируем данные и добавляем order если нет
|
||
localCards.value = JSON.parse(JSON.stringify(newCards)).map((card, idx) => ({
|
||
...card,
|
||
order: card.order ?? idx
|
||
}))
|
||
}
|
||
}, { immediate: true })
|
||
|
||
// Собираем колонки с карточками (используем localCards, сортируем по order)
|
||
const columnsWithCards = computed(() => {
|
||
return props.columns.map(col => ({
|
||
id: col.id,
|
||
title: col.name,
|
||
color: col.color,
|
||
cards: localCards.value
|
||
.filter(card => card.column_id === col.id)
|
||
.sort((a, b) => a.order - b.order)
|
||
.map(card => ({
|
||
id: card.id_card,
|
||
title: card.title,
|
||
description: card.descript,
|
||
details: card.descript_full,
|
||
departmentId: card.id_department,
|
||
labelId: card.id_label,
|
||
assignee: card.avatar_img,
|
||
dueDate: card.date,
|
||
dateCreate: card.date_create,
|
||
files: card.files || (card.file_img || []).map(f => ({
|
||
name: f.name,
|
||
url: f.url,
|
||
size: f.size,
|
||
preview: f.url
|
||
}))
|
||
}))
|
||
}))
|
||
})
|
||
|
||
// Фильтруем колонки по активному отделу
|
||
const filteredColumns = computed(() => {
|
||
if (!props.activeDepartment) return columnsWithCards.value
|
||
|
||
return columnsWithCards.value.map(col => ({
|
||
...col,
|
||
cards: col.cards.filter(card => card.departmentId === props.activeDepartment)
|
||
}))
|
||
})
|
||
|
||
const filteredTotalTasks = computed(() => {
|
||
return filteredColumns.value.reduce((sum, col) => sum + col.cards.length, 0)
|
||
})
|
||
|
||
const inProgressTasks = computed(() => {
|
||
const col = filteredColumns.value.find(c => c.id === 3) // В работе
|
||
return col ? col.cards.length : 0
|
||
})
|
||
|
||
const completedTasks = computed(() => {
|
||
const col = filteredColumns.value.find(c => c.id === 5) // Готово
|
||
return col ? col.cards.length : 0
|
||
})
|
||
|
||
// Отправляем статистику в родителя
|
||
watch([filteredTotalTasks, inProgressTasks, completedTasks], () => {
|
||
emit('stats-updated', {
|
||
total: filteredTotalTasks.value,
|
||
inProgress: inProgressTasks.value,
|
||
done: completedTasks.value
|
||
})
|
||
}, { immediate: true })
|
||
|
||
const handleDropCard = ({ cardId, fromColumnId, toColumnId, toIndex }) => {
|
||
const card = localCards.value.find(c => c.id_card === cardId)
|
||
if (!card) return
|
||
|
||
// Меняем колонку
|
||
card.column_id = toColumnId
|
||
|
||
// Получаем карточки целевой колонки (без перемещаемой)
|
||
const columnCards = localCards.value
|
||
.filter(c => c.column_id === toColumnId && c.id_card !== cardId)
|
||
.sort((a, b) => a.order - b.order)
|
||
|
||
// Вставляем карточку в нужную позицию и пересчитываем order
|
||
columnCards.splice(toIndex, 0, card)
|
||
columnCards.forEach((c, idx) => {
|
||
c.order = idx
|
||
})
|
||
|
||
// TODO: отправить изменение на сервер
|
||
}
|
||
|
||
// Генератор id для новых карточек
|
||
let nextCardId = 100
|
||
|
||
// Методы для модалки
|
||
const saveTask = (taskData, columnId) => {
|
||
if (taskData.id) {
|
||
// Редактирование существующей карточки
|
||
const card = localCards.value.find(c => c.id_card === taskData.id)
|
||
if (card) {
|
||
card.title = taskData.title
|
||
card.descript = taskData.description
|
||
card.descript_full = taskData.details
|
||
card.id_department = taskData.departmentId
|
||
card.id_label = taskData.labelId
|
||
card.date = taskData.dueDate
|
||
card.avatar_img = taskData.assignee
|
||
card.files = taskData.files || []
|
||
}
|
||
} else {
|
||
// Создание новой карточки (в конец колонки)
|
||
const columnCards = localCards.value.filter(c => c.column_id === columnId)
|
||
const maxOrder = columnCards.length > 0
|
||
? Math.max(...columnCards.map(c => c.order)) + 1
|
||
: 0
|
||
|
||
localCards.value.push({
|
||
id_card: nextCardId++,
|
||
id_department: taskData.departmentId,
|
||
id_label: taskData.labelId,
|
||
title: taskData.title,
|
||
descript: taskData.description,
|
||
descript_full: taskData.details,
|
||
avatar_img: taskData.assignee,
|
||
column_id: columnId,
|
||
date: taskData.dueDate,
|
||
date_create: new Date().toISOString().split('T')[0],
|
||
order: maxOrder,
|
||
files: taskData.files || []
|
||
})
|
||
}
|
||
|
||
// TODO: отправить на сервер
|
||
}
|
||
|
||
const deleteTask = (cardId, columnId) => {
|
||
const index = localCards.value.findIndex(c => c.id_card === cardId)
|
||
if (index !== -1) {
|
||
localCards.value.splice(index, 1)
|
||
}
|
||
|
||
// TODO: отправить на сервер
|
||
}
|
||
|
||
defineExpose({ saveTask, deleteTask })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.board {
|
||
min-width: max-content;
|
||
}
|
||
|
||
.columns {
|
||
display: flex;
|
||
gap: 20px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
</style>
|