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 (
{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 ? (
) : 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