1
0
Files
TaskBoard/front_vue/src/components/Board.vue
Falknat 7e1482f515 Исправление ошибок фронта
Правим фронт от ошибок
2026-01-15 15:27:39 +07:00

440 lines
12 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>
<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>