440 lines
12 KiB
Vue
440 lines
12 KiB
Vue
<template>
|
||
<div class="board" :class="{ mobile: isMobile }">
|
||
<div class="columns" ref="columnsRef" @scroll="onColumnsScroll">
|
||
<Column
|
||
v-for="column in filteredColumns"
|
||
:key="column.id"
|
||
:column="column"
|
||
:departments="departments"
|
||
:labels="labels"
|
||
:done-column-id="doneColumnId"
|
||
@drop-card="handleDropCard"
|
||
@open-task="(card) => emit('open-task', { card, columnId: column.id })"
|
||
@create-task="emit('create-task', column.id)"
|
||
@archive-task="archiveTask"
|
||
@move-request="handleMoveRequest"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Мобильный индикатор снизу (над навигацией) -->
|
||
<div v-if="isMobile" class="mobile-column-footer">
|
||
<div class="current-column-title">{{ currentColumnTitle }}</div>
|
||
<div class="column-indicators">
|
||
<button
|
||
v-for="(column, index) in filteredColumns"
|
||
:key="column.id"
|
||
class="indicator-dot"
|
||
:class="{ active: currentColumnIndex === index }"
|
||
:style="{ '--dot-color': column.color }"
|
||
@click="scrollToColumn(index)"
|
||
></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Мобильная панель перемещения карточки -->
|
||
<MoveCardPanel
|
||
:open="movePanel.open"
|
||
:card-id="movePanel.cardId"
|
||
:card-title="movePanel.cardTitle"
|
||
:current-column-id="movePanel.columnId"
|
||
:columns="movePanelColumns"
|
||
@close="closeMovePanel"
|
||
@move="handleDropCard"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onUpdated, watch } from 'vue'
|
||
import Column from './Column.vue'
|
||
import MoveCardPanel from './ui/MoveCardPanel.vue'
|
||
import { cardsApi } from '../api'
|
||
import { useMobile } from '../composables/useMobile'
|
||
|
||
const { isMobile } = useMobile()
|
||
|
||
// Состояние для мобильной панели перемещения
|
||
const movePanel = ref({
|
||
open: false,
|
||
cardId: null,
|
||
cardTitle: '',
|
||
columnId: null
|
||
})
|
||
|
||
const handleMoveRequest = ({ cardId, cardTitle, columnId }) => {
|
||
movePanel.value = {
|
||
open: true,
|
||
cardId,
|
||
cardTitle,
|
||
columnId
|
||
}
|
||
}
|
||
|
||
const closeMovePanel = () => {
|
||
movePanel.value.open = false
|
||
}
|
||
|
||
// Колонки для панели перемещения (только id, title, color)
|
||
const movePanelColumns = computed(() => {
|
||
return props.columns.map(col => ({
|
||
id: col.id,
|
||
title: col.name_columns,
|
||
color: col.color
|
||
}))
|
||
})
|
||
|
||
// Мобильный свайп
|
||
const columnsRef = ref(null)
|
||
const currentColumnIndex = ref(0)
|
||
|
||
const onColumnsScroll = () => {
|
||
if (!columnsRef.value || !isMobile.value) return
|
||
const scrollLeft = columnsRef.value.scrollLeft
|
||
const columnWidth = columnsRef.value.scrollWidth / filteredColumns.value.length
|
||
currentColumnIndex.value = Math.round(scrollLeft / columnWidth)
|
||
}
|
||
|
||
const scrollToColumn = (index) => {
|
||
if (!columnsRef.value) return
|
||
const columnWidth = columnsRef.value.scrollWidth / filteredColumns.value.length
|
||
columnsRef.value.scrollTo({
|
||
left: index * columnWidth,
|
||
behavior: 'smooth'
|
||
})
|
||
}
|
||
|
||
// Название текущей колонки для мобильного индикатора
|
||
const currentColumnTitle = computed(() => {
|
||
const col = filteredColumns.value[currentColumnIndex.value]
|
||
return col ? col.title : ''
|
||
})
|
||
|
||
const props = defineProps({
|
||
activeDepartment: Number,
|
||
doneColumnId: 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) => {
|
||
// Копируем данные и добавляем 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_columns,
|
||
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,
|
||
title: card.title,
|
||
description: card.descript,
|
||
details: card.descript_full,
|
||
departmentId: card.id_department,
|
||
labelId: card.id_label,
|
||
accountId: card.id_account,
|
||
assignee: card.avatar_img,
|
||
dueDate: card.date,
|
||
dateCreate: card.date_create,
|
||
dateClosed: card.date_closed,
|
||
comments_count: card.comments_count || 0,
|
||
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 === 2) // В работе
|
||
return col ? col.cards.length : 0
|
||
})
|
||
|
||
const completedTasks = computed(() => {
|
||
if (!props.doneColumnId) return 0
|
||
const col = filteredColumns.value.find(c => c.id === props.doneColumnId)
|
||
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 = async ({ cardId, fromColumnId, toColumnId, toIndex }) => {
|
||
const card = localCards.value.find(c => c.id === cardId)
|
||
if (!card) return
|
||
|
||
// Локально обновляем для мгновенного отклика
|
||
card.column_id = toColumnId
|
||
|
||
// Обновляем date_closed при перемещении в/из колонки "Готово"
|
||
if (props.doneColumnId && toColumnId === props.doneColumnId && fromColumnId !== props.doneColumnId) {
|
||
card.date_closed = new Date().toISOString()
|
||
} else if (props.doneColumnId && fromColumnId === props.doneColumnId && toColumnId !== props.doneColumnId) {
|
||
card.date_closed = null
|
||
}
|
||
|
||
// Получаем карточки целевой колонки (без перемещаемой)
|
||
const columnCards = localCards.value
|
||
.filter(c => c.column_id === toColumnId && c.id !== cardId)
|
||
.sort((a, b) => a.order - b.order)
|
||
|
||
// Вставляем карточку в нужную позицию и пересчитываем order локально
|
||
columnCards.splice(toIndex, 0, card)
|
||
columnCards.forEach((c, idx) => {
|
||
c.order = idx
|
||
})
|
||
|
||
// Отправляем на сервер (сервер сам пересчитает order для всех)
|
||
await cardsApi.updateOrder(cardId, toColumnId, toIndex)
|
||
}
|
||
|
||
// Генератор id для новых карточек
|
||
let nextCardId = 100
|
||
|
||
// Методы для модалки
|
||
const saveTask = async (taskData, columnId) => {
|
||
if (taskData.id) {
|
||
// Редактирование существующей карточки
|
||
const card = localCards.value.find(c => c.id === 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.id_account = taskData.accountId
|
||
card.avatar_img = taskData.assignee
|
||
card.files = taskData.files || []
|
||
|
||
// Отправляем на сервер
|
||
await cardsApi.update({
|
||
id: taskData.id,
|
||
id_department: taskData.departmentId,
|
||
id_label: taskData.labelId,
|
||
id_account: taskData.accountId,
|
||
column_id: card.column_id,
|
||
order: card.order,
|
||
date: taskData.dueDate,
|
||
title: taskData.title,
|
||
descript: taskData.description,
|
||
descript_full: taskData.details
|
||
})
|
||
}
|
||
} 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
|
||
|
||
// Отправляем на сервер
|
||
const result = await cardsApi.create({
|
||
id_project: taskData.id_project,
|
||
id_department: taskData.departmentId,
|
||
id_label: taskData.labelId,
|
||
id_account: taskData.accountId,
|
||
column_id: columnId,
|
||
order: maxOrder,
|
||
date: taskData.dueDate,
|
||
title: taskData.title,
|
||
descript: taskData.description,
|
||
descript_full: taskData.details,
|
||
files: taskData.files || []
|
||
})
|
||
|
||
if (result.success) {
|
||
// Добавляем локально с ID от сервера
|
||
localCards.value.push({
|
||
id: parseInt(result.id),
|
||
id_department: taskData.departmentId,
|
||
id_label: taskData.labelId,
|
||
id_account: taskData.accountId,
|
||
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: result.files || []
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
const deleteTask = async (cardId, columnId) => {
|
||
// Удаляем на сервере
|
||
const result = await cardsApi.delete(cardId)
|
||
|
||
if (result.success) {
|
||
// Удаляем локально
|
||
const index = localCards.value.findIndex(c => c.id === cardId)
|
||
if (index !== -1) {
|
||
localCards.value.splice(index, 1)
|
||
}
|
||
}
|
||
}
|
||
|
||
const archiveTask = async (cardId) => {
|
||
// Архивируем на сервере
|
||
const result = await cardsApi.setArchive(cardId, 1)
|
||
|
||
if (result.success) {
|
||
// Удаляем из локального списка (задача уходит в архив)
|
||
const index = localCards.value.findIndex(c => c.id === cardId)
|
||
if (index !== -1) {
|
||
localCards.value.splice(index, 1)
|
||
}
|
||
}
|
||
}
|
||
|
||
defineExpose({ saveTask, deleteTask, archiveTask })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.board {
|
||
min-width: max-content;
|
||
}
|
||
|
||
.columns {
|
||
display: flex;
|
||
gap: 20px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
/* ========== MOBILE: колонки со свайпом ========== */
|
||
.board.mobile {
|
||
min-width: auto;
|
||
width: 100%;
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.board.mobile .columns {
|
||
flex: 1;
|
||
display: flex;
|
||
gap: 0;
|
||
padding: 0;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
scroll-snap-type: x mandatory;
|
||
scroll-behavior: smooth;
|
||
-webkit-overflow-scrolling: touch;
|
||
scrollbar-width: none;
|
||
-ms-overflow-style: none;
|
||
/* Отключаем вертикальный скролл на уровне этого элемента */
|
||
overscroll-behavior: contain;
|
||
touch-action: pan-x;
|
||
}
|
||
|
||
.board.mobile .columns::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
|
||
/* Мобильный футер с индикатором колонок */
|
||
.mobile-column-footer {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 12px 16px;
|
||
background: var(--bg-body);
|
||
flex-shrink: 0;
|
||
min-height: 60px;
|
||
}
|
||
|
||
.current-column-title {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
text-align: center;
|
||
}
|
||
|
||
.column-indicators {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.indicator-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.2);
|
||
border: none;
|
||
padding: 0;
|
||
cursor: pointer;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.indicator-dot.active {
|
||
width: 28px;
|
||
border-radius: 5px;
|
||
background: var(--dot-color, var(--accent));
|
||
}
|
||
</style>
|