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 (
{/* Заголовок с кнопкой закрытия */}
{/* Контент */}
{/* Поле ввода */}
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 && (
{error}
)}
{/* Кнопка подтверждения (прибита к низу) */}
) } // Компонент экрана переноса проекта 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 (
{/* Заголовок с кнопкой закрытия */}
{/* Контент */}
{/* Текущее имя проекта */}
Текущее имя проекта
{project.name}
{/* Стрелочка вниз */}
{/* Поле ввода */}
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 && (
{validationError}
)}
{/* Список проектов */} {allProjects.length > 0 && (
Выберите существующий проект:
{allProjects.map((p) => ( ))}
)}
{/* Кнопка подтверждения (прибита к низу) */}
) } // Компонент для сортируемого элемента проекта 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 (
{project.name}
{onMenuClick && ( )}
) } // Компонент для пустого слота (droppable) function DroppableSlot({ containerId, isEmpty, maxItems, currentCount }) { const { setNodeRef, isOver } = useDroppable({ id: containerId, }) return (
{isEmpty ? 'Перетащите проект сюда' : `Можно добавить еще ${maxItems - currentCount} проект${maxItems - currentCount > 1 ? 'а' : ''}`}
) } // Компонент для слота приоритета function PrioritySlot({ title, projects, allProjects, onMenuClick, maxItems = null, containerId, onAddClick }) { return (
{title}
{projects.length === 0 && ( )} {projects.map((project, index) => ( ))} {onAddClick && containerId === 'low' && ( )}
) } 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 (
{onNavigate && ( )} {projectsError && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) && ( )} {projectsLoading && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) ? (
Загрузка...
) : (
p.name)} strategy={verticalListSortingStrategy}> p.name)} strategy={verticalListSortingStrategy}> p.name)} strategy={verticalListSortingStrategy}> setShowAddScreen(true)} /> {!maxPriority.length && !mediumPriority.length && !lowPriority.length && (
Проекты не найдены
)}
{typeof document !== 'undefined' ? createPortal( {activeProject ? (
{activeProject.name}
) : null}
, document.body ) : null}
)} {/* Модальное окно для действий с проектом */} {selectedProject && !showMoveScreen && (
e.stopPropagation()}>

{selectedProject.name}

)} {/* Экран переноса проекта */} {showMoveScreen && selectedProject && ( { 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 && ( setShowAddScreen(false)} onSuccess={() => { setShowAddScreen(false) fetchProjects() }} onError={(errorMessage) => { setToastMessage({ text: errorMessage, type: 'error' }) }} /> )} {toastMessage && ( setToastMessage(null)} /> )}
) } export default ProjectPriorityManager