Files
play-life/play-life-web/src/components/ProjectPriorityManager.jsx
poignatov 932dba8682
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 34s
Унификация отображения ошибок: LoadingError для загрузки, Toast для действий
2026-01-11 15:51:28 +03:00

1045 lines
38 KiB
JavaScript
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.
import { useState, useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
useDroppable,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
import { useAuth } from './auth/AuthContext'
import LoadingError from './LoadingError'
import Toast from './Toast'
import './Integrations.css'
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
const PROJECTS_API_URL = '/projects'
const PRIORITY_UPDATE_API_URL = '/project/priority'
const PROJECT_MOVE_API_URL = '/project/move'
const PROJECT_CREATE_API_URL = '/project/create'
// Компонент экрана добавления проекта
function AddProjectScreen({ onClose, onSuccess, onError }) {
const { authFetch } = useAuth()
const [projectName, setProjectName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [validationError, setValidationError] = useState(null)
const handleSubmit = async () => {
if (!projectName.trim()) {
setValidationError('Введите название проекта')
return
}
setIsSubmitting(true)
setValidationError(null)
try {
const response = await authFetch(PROJECT_CREATE_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: projectName.trim(),
}),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(errorText || 'Ошибка при создании проекта')
}
onSuccess()
} catch (err) {
console.error('Ошибка создания проекта:', err)
if (onError) {
onError(err.message || 'Ошибка при создании проекта')
}
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg max-w-md w-90 shadow-lg max-h-[90vh] flex flex-col">
{/* Заголовок с кнопкой закрытия */}
<div className="flex justify-end p-4 border-b border-gray-200">
<button
onClick={onClose}
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Закрыть"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
{/* Контент */}
<div className="flex-1 overflow-y-auto p-6">
{/* Поле ввода */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Название проекта
</label>
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && projectName.trim() && !isSubmitting) {
handleSubmit()
}
}}
placeholder="Введите название проекта"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
autoFocus
/>
{error && (
<div className="mt-2 text-sm text-red-600">{error}</div>
)}
</div>
</div>
{/* Кнопка подтверждения (прибита к низу) */}
<div className="p-6 border-t border-gray-200">
<button
onClick={handleSubmit}
disabled={isSubmitting || !projectName.trim()}
className="w-full px-4 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Обработка...' : 'Добавить'}
</button>
</div>
</div>
</div>
)
}
// Компонент экрана переноса проекта
function MoveProjectScreen({ project, allProjects, onClose, onSuccess, onError }) {
const [newProjectName, setNewProjectName] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [validationError, setValidationError] = useState(null)
const handleProjectClick = (projectName) => {
setNewProjectName(projectName)
}
const handleSubmit = async () => {
if (!newProjectName.trim()) {
setValidationError('Введите название проекта')
return
}
setIsSubmitting(true)
setValidationError(null)
try {
const projectId = project.id ?? project.name
const response = await authFetch(PROJECT_MOVE_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: projectId,
new_name: newProjectName.trim(),
}),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(errorText || 'Ошибка при переносе проекта')
}
onSuccess()
} catch (err) {
console.error('Ошибка переноса проекта:', err)
if (onError) {
onError(err.message || 'Ошибка при переносе проекта')
}
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg max-w-md w-90 shadow-lg max-h-[90vh] flex flex-col">
{/* Заголовок с кнопкой закрытия */}
<div className="flex justify-end p-4 border-b border-gray-200">
<button
onClick={onClose}
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Закрыть"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
{/* Контент */}
<div className="flex-1 overflow-y-auto p-6">
{/* Текущее имя проекта */}
<div className="text-center mb-4">
<div className="text-sm text-gray-600 mb-2">Текущее имя проекта</div>
<div className="text-lg font-semibold text-gray-800">{project.name}</div>
</div>
{/* Стрелочка вниз */}
<div className="flex justify-center mb-4">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-400">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</div>
{/* Поле ввода */}
<div className="mb-6">
<input
type="text"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Введите новое название проекта"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
{validationError && (
<div className="mt-2 text-sm text-red-600">{validationError}</div>
)}
</div>
{/* Список проектов */}
{allProjects.length > 0 && (
<div className="mb-6">
<div className="text-sm text-gray-600 mb-2">Выберите существующий проект:</div>
<div className="space-y-2 max-h-48 overflow-y-auto">
{allProjects.map((p) => (
<button
key={p.id ?? p.name}
onClick={() => handleProjectClick(p.name)}
className="w-full text-left px-4 py-2 bg-gray-50 hover:bg-gray-100 rounded-lg border border-gray-200 hover:border-indigo-300 transition-colors"
>
{p.name}
</button>
))}
</div>
</div>
)}
</div>
{/* Кнопка подтверждения (прибита к низу) */}
<div className="p-6 border-t border-gray-200">
<button
onClick={handleSubmit}
disabled={isSubmitting || !newProjectName.trim()}
className="w-full px-4 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Обработка...' : 'Подтвердить'}
</button>
</div>
</div>
</div>
)
}
// Компонент для сортируемого элемента проекта
function SortableProjectItem({ project, index, allProjects, onMenuClick }) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: project.name })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const projectColor = getProjectColor(project.name, allProjects)
return (
<div
ref={setNodeRef}
data-id={project.name}
style={style}
className={`bg-white rounded-lg p-3 border-2 border-gray-200 shadow-sm hover:shadow-md transition-all duration-200 ${
isDragging ? 'border-indigo-400' : ''
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1">
<div
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600"
style={{ touchAction: 'none' }}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<circle cx="7" cy="7" r="1.5" />
<circle cx="13" cy="7" r="1.5" />
<circle cx="7" cy="13" r="1.5" />
<circle cx="13" cy="13" r="1.5" />
</svg>
</div>
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: projectColor }}
></div>
<span className="font-semibold text-gray-800">{project.name}</span>
</div>
{onMenuClick && (
<button
onClick={(e) => {
e.stopPropagation()
onMenuClick(project, e)
}}
className="ml-2 text-gray-400 hover:text-gray-600 transition-colors text-xl font-bold"
title="Меню"
>
</button>
)}
</div>
</div>
)
}
// Компонент для пустого слота (droppable)
function DroppableSlot({ containerId, isEmpty, maxItems, currentCount }) {
const { setNodeRef, isOver } = useDroppable({
id: containerId,
})
return (
<div
ref={setNodeRef}
className={`bg-gray-50 border-2 border-dashed rounded-lg p-4 text-center text-sm transition-colors ${
isOver
? 'border-indigo-400 bg-indigo-50 text-indigo-600'
: 'border-gray-300 text-gray-400'
}`}
>
{isEmpty
? 'Перетащите проект сюда'
: `Можно добавить еще ${maxItems - currentCount} проект${maxItems - currentCount > 1 ? 'а' : ''}`}
</div>
)
}
// Компонент для слота приоритета
function PrioritySlot({ title, projects, allProjects, onMenuClick, maxItems = null, containerId, onAddClick }) {
return (
<div className="mb-6">
<div className="text-sm font-semibold text-gray-600 mb-2">{title}</div>
<div className="space-y-2 min-h-[60px]">
{projects.length === 0 && (
<DroppableSlot containerId={containerId} isEmpty={true} maxItems={maxItems} currentCount={0} />
)}
{projects.map((project, index) => (
<SortableProjectItem
key={project.name}
project={project}
index={index}
allProjects={allProjects}
onMenuClick={onMenuClick}
/>
))}
{onAddClick && containerId === 'low' && (
<button
onClick={onAddClick}
className="w-full bg-white rounded-lg p-3 border-2 border-gray-200 shadow-sm hover:shadow-md transition-all duration-200 hover:border-indigo-400 text-gray-600 hover:text-indigo-600 font-semibold"
>
+ Добавить
</button>
)}
</div>
</div>
)
}
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) {
const { authFetch } = useAuth()
const [projectsLoading, setProjectsLoading] = useState(false)
const [projectsError, setProjectsError] = useState(null)
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
const [toastMessage, setToastMessage] = useState(null)
// Уведомляем родительский компонент об изменении состояния загрузки
useEffect(() => {
if (onLoadingChange) {
onLoadingChange(projectsLoading)
}
}, [projectsLoading, onLoadingChange])
// Уведомляем родительский компонент об изменении ошибок
useEffect(() => {
if (onErrorChange) {
onErrorChange(projectsError)
}
}, [projectsError, onErrorChange])
const [allProjects, setAllProjects] = useState([])
const [maxPriority, setMaxPriority] = useState([])
const [mediumPriority, setMediumPriority] = useState([])
const [lowPriority, setLowPriority] = useState([])
const [activeId, setActiveId] = useState(null)
const [selectedProject, setSelectedProject] = useState(null) // Для модального окна
const [showMoveScreen, setShowMoveScreen] = useState(false) // Для экрана переноса
const [showAddScreen, setShowAddScreen] = useState(false) // Для экрана добавления
const scrollContainerRef = useRef(null)
const hasFetchedRef = useRef(false)
const skipNextEffectRef = useRef(false)
const lastRefreshTriggerRef = useRef(0) // Отслеживаем последний обработанный refreshTrigger
const isLoadingRef = useRef(false) // Отслеживаем, идет ли сейчас загрузка
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 15, // Увеличиваем расстояние для активации, чтобы дать больше времени для скролла
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
// Получаем резервный список проектов из уже загруженных данных,
// если API для приоритетов недоступен.
const getFallbackProjects = useCallback(() => {
return getAllProjectsSorted(allProjectsData, currentWeekData)
}, [allProjectsData, currentWeekData])
const normalizeProjects = useCallback((projectsArray) => {
const normalizedProjects = projectsArray
.map(item => {
const name = item?.name || item?.project || item?.project_name || item?.title
if (!name) return null
const id = item?.project_id ?? item?.id ?? item?.projectId ?? null
const priorityValue = item?.priority ?? item?.priority_value ?? item?.priority_level ?? null
return { id, name, priority: priorityValue }
})
.filter(Boolean)
const uniqueProjects = []
const seenNames = new Set()
normalizedProjects.forEach(item => {
const key = item.id ?? item.name
if (!seenNames.has(key)) {
seenNames.add(key)
uniqueProjects.push(item)
}
})
const max = []
const medium = []
const low = []
uniqueProjects.forEach(item => {
const projectEntry = { name: item.name, id: item.id }
if (item.priority === 1) {
max.push(projectEntry)
} else if (item.priority === 2) {
medium.push(projectEntry)
} else {
low.push(projectEntry)
}
})
return {
uniqueProjects,
max,
medium,
low,
}
}, [])
const applyProjects = useCallback((projectsArray) => {
const { uniqueProjects, max, medium, low } = normalizeProjects(projectsArray)
setAllProjects(uniqueProjects.map(item => item.name))
setMaxPriority(max)
setMediumPriority(medium)
setLowPriority(low)
}, [normalizeProjects])
const fetchProjects = useCallback(async (isBackground = false) => {
// Предотвращаем параллельные загрузки
if (isLoadingRef.current) {
return
}
try {
isLoadingRef.current = true
// Показываем загрузку только если это не фоновая загрузка
if (!isBackground) {
setProjectsLoading(true)
}
setProjectsError(null)
const response = await authFetch(PROJECTS_API_URL)
if (!response.ok) {
throw new Error('Не удалось загрузить проекты')
}
const jsonData = await response.json()
const projectsArray = Array.isArray(jsonData)
? jsonData
: Array.isArray(jsonData?.projects)
? jsonData.projects
: Array.isArray(jsonData?.data)
? jsonData.data
: []
applyProjects(projectsArray)
setHasDataCache(true) // Отмечаем, что данные загружены
} catch (error) {
console.error('Ошибка загрузки проектов:', error)
setProjectsError(error.message || 'Ошибка загрузки проектов')
const fallbackProjects = getFallbackProjects()
if (fallbackProjects.length > 0) {
setAllProjects(fallbackProjects)
setMaxPriority([])
setMediumPriority([])
setLowPriority(fallbackProjects.map(name => ({ name })))
setHasDataCache(true) // Отмечаем, что есть fallback данные
}
} finally {
isLoadingRef.current = false
setProjectsLoading(false)
}
}, [applyProjects, getFallbackProjects])
useEffect(() => {
// Если таб не должен загружаться, не делаем ничего
if (!shouldLoad) {
// Сбрасываем флаг загрузки, если таб стал неактивным
if (hasFetchedRef.current) {
hasFetchedRef.current = false
}
return
}
// Если загрузка уже идет, не запускаем еще одну
if (isLoadingRef.current) {
return
}
// Если refreshTrigger равен 0 и мы еще не загружали - ждем, пока триггер не будет установлен
// Это предотвращает загрузку при первом монтировании, когда shouldLoad становится true,
// но refreshTrigger еще не установлен
if (refreshTrigger === 0 && !hasFetchedRef.current) {
return
}
// Проверяем, был ли этот refreshTrigger уже обработан
if (refreshTrigger === lastRefreshTriggerRef.current && hasFetchedRef.current) {
return // Уже обработали этот триггер
}
// Если уже загружали и нет нового триггера обновления - не загружаем снова
if (hasFetchedRef.current && refreshTrigger === lastRefreshTriggerRef.current) return
// Определяем, есть ли кеш (данные уже загружены)
const hasCache = hasDataCache && (maxPriority.length > 0 || mediumPriority.length > 0 || lowPriority.length > 0)
// Отмечаем, что обрабатываем этот триггер ПЕРЕД загрузкой
lastRefreshTriggerRef.current = refreshTrigger
if (refreshTrigger > 0) {
// Если есть триггер обновления, сбрасываем флаг загрузки
hasFetchedRef.current = false
}
// Устанавливаем флаг загрузки перед вызовом
hasFetchedRef.current = true
// Загружаем: если есть кеш - фоново, если нет - с индикатором
fetchProjects(hasCache)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchProjects, shouldLoad, refreshTrigger]) // hasDataCache и длины массивов проверяются внутри эффекта, не добавляем в зависимости
const buildAssignments = useCallback(() => {
const map = new Map()
maxPriority.forEach(p => {
map.set(p.id ?? p.name, { id: p.id, priority: 1 })
})
mediumPriority.forEach(p => {
map.set(p.id ?? p.name, { id: p.id, priority: 2 })
})
lowPriority.forEach(p => {
map.set(p.id ?? p.name, { id: p.id, priority: null })
})
return map
}, [lowPriority, maxPriority, mediumPriority])
const prevAssignmentsRef = useRef(new Map())
const initializedAssignmentsRef = useRef(false)
const sendPriorityChanges = useCallback(async (changes) => {
if (!changes.length) return
try {
await authFetch(PRIORITY_UPDATE_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
})
} catch (e) {
console.error('Ошибка отправки изменений приоритета', e)
}
}, [])
useEffect(() => {
const current = buildAssignments()
if (!initializedAssignmentsRef.current) {
prevAssignmentsRef.current = current
initializedAssignmentsRef.current = true
return
}
if (skipNextEffectRef.current) {
skipNextEffectRef.current = false
prevAssignmentsRef.current = current
return
}
const prev = prevAssignmentsRef.current
const allKeys = new Set([...prev.keys(), ...current.keys()])
const changes = []
allKeys.forEach(key => {
const prevItem = prev.get(key)
const currItem = current.get(key)
const prevPriority = prevItem?.priority ?? null
const currPriority = currItem?.priority ?? null
const id = currItem?.id ?? prevItem?.id
if (!id) return
if (prevPriority !== currPriority) {
changes.push({ id, priority: currPriority })
}
})
if (changes.length) {
sendPriorityChanges(changes)
}
prevAssignmentsRef.current = current
}, [buildAssignments, sendPriorityChanges])
const findProjectContainer = (projectName) => {
if (maxPriority.find(p => p.name === projectName)) return 'max'
if (mediumPriority.find(p => p.name === projectName)) return 'medium'
if (lowPriority.find(p => p.name === projectName)) return 'low'
return null
}
const isValidContainer = (containerId) => {
return containerId === 'max' || containerId === 'medium' || containerId === 'low'
}
const handleDragStart = (event) => {
setActiveId(event.active.id)
// Находим скроллируемый контейнер и отключаем его скролл
if (!scrollContainerRef.current) {
// Ищем родительский скроллируемый контейнер через DOM
const activeElement = document.querySelector(`[data-id="${event.active.id}"]`)
if (activeElement) {
const container = activeElement.closest('.overflow-y-auto')
if (container) {
scrollContainerRef.current = container
container.style.overflow = 'hidden'
}
}
} else {
scrollContainerRef.current.style.overflow = 'hidden'
}
}
const handleDragCancel = () => {
setActiveId(null)
// Включаем скролл обратно
if (scrollContainerRef.current) {
scrollContainerRef.current.style.overflow = 'auto'
scrollContainerRef.current = null
}
}
const handleDragEnd = (event) => {
const { active, over } = event
setActiveId(null)
// Включаем скролл обратно
if (scrollContainerRef.current) {
scrollContainerRef.current.style.overflow = 'auto'
scrollContainerRef.current = null
}
if (!over) return
const activeId = active.id
const overId = over.id
const activeContainer = findProjectContainer(activeId)
// Проверяем, является ли overId контейнером или проектом
let overContainer = findProjectContainer(overId)
if (!overContainer && isValidContainer(overId)) {
overContainer = overId
}
if (!activeContainer) return
if (!overContainer) return
// Если перетаскиваем в тот же контейнер
if (activeContainer === overContainer) {
let items
let setItems
if (activeContainer === 'max') {
items = maxPriority
setItems = setMaxPriority
} else if (activeContainer === 'medium') {
items = mediumPriority
setItems = setMediumPriority
} else {
items = lowPriority
setItems = setLowPriority
}
const oldIndex = items.findIndex(p => p.name === activeId)
const newIndex = items.findIndex(p => p.name === overId)
if (oldIndex !== -1 && newIndex !== -1) {
setItems(arrayMove(items, oldIndex, newIndex))
}
return
}
// Перемещаем между контейнерами
const activeProject = [
...maxPriority,
...mediumPriority,
...lowPriority,
].find(p => p.name === activeId)
if (!activeProject) return
// Если контейнеры одинаковые, ничего не делаем (уже обработано выше)
if (activeContainer === overContainer) return
// Удаляем из старого контейнера
if (activeContainer === 'max') {
setMaxPriority(prev => prev.filter(p => p.name !== activeId))
} else if (activeContainer === 'medium') {
setMediumPriority(prev => prev.filter(p => p.name !== activeId))
} else {
setLowPriority(prev => prev.filter(p => p.name !== activeId))
}
// Добавляем в новый контейнер
if (overContainer === 'max') {
// Если контейнер max уже заполнен, заменяем первый элемент
if (maxPriority.length >= 1) {
const oldProject = maxPriority[0]
setMaxPriority([activeProject])
// Старый проект перемещаем в low
setLowPriority(prev => [...prev, oldProject])
} else {
// Контейнер пустой, просто добавляем
setMaxPriority([activeProject])
}
} else if (overContainer === 'medium') {
// Если контейнер medium уже заполнен (2 элемента)
if (mediumPriority.length >= 2) {
// Если перетаскиваем на конкретный проект, заменяем его
const overIndex = mediumPriority.findIndex(p => p.name === overId)
if (overIndex !== -1) {
const oldProject = mediumPriority[overIndex]
const newItems = [...mediumPriority]
newItems[overIndex] = activeProject
setMediumPriority(newItems)
setLowPriority(prev => [...prev, oldProject])
} else {
// Перетаскиваем на пустой слот, заменяем последний
const oldProject = mediumPriority[mediumPriority.length - 1]
setMediumPriority([mediumPriority[0], activeProject])
setLowPriority(prev => [...prev, oldProject])
}
} else {
// Есть место, добавляем в нужную позицию или в конец
const overIndex = mediumPriority.findIndex(p => p.name === overId)
if (overIndex !== -1) {
const newItems = [...mediumPriority]
newItems.splice(overIndex, 0, activeProject)
setMediumPriority(newItems)
} else {
// Перетаскиваем на пустой слот, добавляем в конец
setMediumPriority([...mediumPriority, activeProject])
}
}
} else {
// Для low priority просто добавляем
const overIndex = lowPriority.findIndex(p => p.name === overId)
if (overIndex !== -1) {
// Перетаскиваем на конкретный проект
const newItems = [...lowPriority]
newItems.splice(overIndex, 0, activeProject)
setLowPriority(newItems)
} else {
// Перетаскиваем на пустой слот, добавляем в конец
setLowPriority([...lowPriority, activeProject])
}
}
}
const getProjectKey = (project) => project?.id ?? project?.name
const moveProjectToLow = (project) => {
const projectKey = getProjectKey(project)
if (!projectKey) return
setLowPriority(prev => {
const filtered = prev.filter(p => getProjectKey(p) !== projectKey)
return [...filtered, project]
})
}
const handleMenuClick = (project, e) => {
e.stopPropagation()
setSelectedProject(project)
}
const handleMove = () => {
if (!selectedProject) return
setShowMoveScreen(true)
}
const handleDelete = async () => {
if (!selectedProject) return
try {
const projectId = selectedProject.id ?? selectedProject.name
const response = await authFetch(`/project/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: projectId }),
})
if (!response.ok) {
throw new Error('Ошибка при удалении проекта')
}
setSelectedProject(null)
// Обновляем список проектов
fetchProjects()
} catch (error) {
console.error('Ошибка удаления проекта:', error)
setToastMessage({ text: error.message || 'Ошибка удаления проекта', type: 'error' })
}
}
const closeModal = () => {
setSelectedProject(null)
}
const allItems = [
...maxPriority.map(p => ({ ...p, container: 'max' })),
...mediumPriority.map(p => ({ ...p, container: 'medium' })),
...lowPriority.map(p => ({ ...p, container: 'low' })),
]
const activeProject = allItems.find(item => item.name === activeId)
return (
<div className="max-w-2xl mx-auto flex flex-col max-h-[calc(100vh-11rem)] pt-[60px]">
{onNavigate && (
<button
onClick={() => onNavigate('current')}
className="close-x-button"
title="Закрыть"
>
</button>
)}
{projectsError && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) && (
<LoadingError onRetry={fetchProjects} />
)}
{projectsLoading && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) ? (
<div className="fixed inset-0 flex justify-center items-center">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка...</div>
</div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div
className="space-y-6 overflow-y-auto flex-1"
style={{ minHeight: 0, touchAction: 'pan-y' }}
>
<SortableContext items={maxPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
<PrioritySlot
title="Максимальный приоритет (1 проект)"
projects={maxPriority}
allProjects={allProjects}
onMenuClick={handleMenuClick}
maxItems={1}
containerId="max"
/>
</SortableContext>
<SortableContext items={mediumPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
<PrioritySlot
title="Средний приоритет (2 проекта)"
projects={mediumPriority}
allProjects={allProjects}
onMenuClick={handleMenuClick}
maxItems={2}
containerId="medium"
/>
</SortableContext>
<SortableContext items={lowPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
<PrioritySlot
title="Остальные проекты"
projects={lowPriority}
allProjects={allProjects}
onMenuClick={handleMenuClick}
containerId="low"
onAddClick={() => setShowAddScreen(true)}
/>
</SortableContext>
{!maxPriority.length && !mediumPriority.length && !lowPriority.length && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 text-center text-gray-600">
Проекты не найдены
</div>
)}
</div>
{typeof document !== 'undefined'
? createPortal(
<DragOverlay>
{activeProject ? (
<div className="bg-white rounded-lg p-3 border-2 border-indigo-400 shadow-lg opacity-90">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: getProjectColor(activeProject.name, allProjects) }}
></div>
<span className="font-semibold text-gray-800">{activeProject.name}</span>
</div>
</div>
) : null}
</DragOverlay>,
document.body
)
: null}
</DndContext>
)}
{/* Модальное окно для действий с проектом */}
{selectedProject && !showMoveScreen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" onClick={closeModal}>
<div className="bg-white rounded-lg p-0 max-w-md w-90 shadow-lg" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-gray-200">
<h3 className="text-xl font-semibold text-gray-800 text-center">{selectedProject.name}</h3>
</div>
<div className="p-6 space-y-3">
<button
onClick={handleMove}
className="w-full px-4 py-3 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium"
>
Перенести
</button>
<button
onClick={handleDelete}
className="w-full px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium"
>
Удалить
</button>
</div>
</div>
</div>
)}
{/* Экран переноса проекта */}
{showMoveScreen && selectedProject && (
<MoveProjectScreen
project={selectedProject}
allProjects={[...maxPriority, ...mediumPriority, ...lowPriority].filter(p => {
const projectId = p.id ?? p.name
const selectedId = selectedProject.id ?? selectedProject.name
return projectId !== selectedId && p.name !== selectedProject.name
})}
onClose={() => {
setShowMoveScreen(false)
setSelectedProject(null)
}}
onSuccess={() => {
setShowMoveScreen(false)
setSelectedProject(null)
fetchProjects()
}}
onError={(errorMessage) => {
setToastMessage({ text: errorMessage, type: 'error' })
}}
/>
)}
{/* Экран добавления проекта */}
{showAddScreen && (
<AddProjectScreen
onClose={() => setShowAddScreen(false)}
onSuccess={() => {
setShowAddScreen(false)
fetchProjects()
}}
onError={(errorMessage) => {
setToastMessage({ text: errorMessage, type: 'error' })
}}
/>
)}
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default ProjectPriorityManager