1
0
Files
TaskBoard/front_vue/src/components/Board.vue
Falknat aca5eb84fd Фронт правки
1. Улучшено отображение на iphone и android в PWA
2026-01-17 09:25:10 +07:00

468 lines
14 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"
@drop-card="handleDropCard"
@open-task="(card) => emit('open-task', { card, columnId: column.id })"
@create-task="emit('create-task', column.id)"
@archive-task="confirmArchive"
@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"
/>
<!-- Диалог подтверждения архивации -->
<ConfirmDialog
:show="archiveDialogOpen"
type="archive"
:action="handleConfirmArchive"
@confirm="archiveDialogOpen = false"
@cancel="archiveDialogOpen = false"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUpdated, watch } from 'vue'
import Column from './Column.vue'
import MoveCardPanel from './ui/MoveCardPanel.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import { cardsApi } from '../api'
import { useMobile } from '../composables/useMobile'
import { useProjectsStore } from '../stores/projects'
const { isMobile } = useMobile()
const store = useProjectsStore()
// Состояние для мобильной панели перемещения
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 store.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,
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 store.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 (!store.doneColumnId) return 0
const col = filteredColumns.value.find(c => c.id === store.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 (store.doneColumnId && toColumnId === store.doneColumnId && fromColumnId !== store.doneColumnId) {
card.date_closed = new Date().toISOString()
} else if (store.doneColumnId && fromColumnId === store.doneColumnId && toColumnId !== store.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 archiveDialogOpen = ref(false)
const cardToArchive = ref(null)
const confirmArchive = (cardId) => {
cardToArchive.value = cardId
archiveDialogOpen.value = true
}
const handleConfirmArchive = async () => {
if (!cardToArchive.value) {
throw new Error('Задача не выбрана')
}
const result = await cardsApi.setArchive(cardToArchive.value, 1)
if (!result.success) {
throw new Error('Ошибка архивации задачи')
}
// Удаляем из локального списка
const index = localCards.value.findIndex(c => c.id === cardToArchive.value)
if (index !== -1) {
localCards.value.splice(index, 1)
}
cardToArchive.value = null
}
// Публичный метод для архивации из TaskPanel (без диалога — там свой)
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;
/* Отступ снизу для footer (70px) + навигации (64px) + safe-area */
padding-bottom: calc(70px + 64px + var(--safe-area-bottom, 0px));
}
.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;
/* Предотвращаем системные жесты (pull-to-refresh) */
overscroll-behavior: contain;
/* Разрешаем и горизонтальный и вертикальный pan - колонки внутри скроллятся вертикально */
touch-action: pan-x pan-y;
}
.board.mobile .columns::-webkit-scrollbar {
display: none;
}
/* Мобильный футер с индикатором колонок - фиксированный над навигацией */
.mobile-column-footer {
position: fixed;
left: 0;
right: 0;
bottom: calc(64px + var(--safe-area-bottom, 0px));
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--bg-body);
z-index: 100;
}
.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>