Files
play-life/play-life-web/src/components/ProjectPriorityManager.jsx
poignatov 49f67ec36d
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 1m18s
6.19.0: Унификация кнопок сохранения в формах
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 10:08:23 +03:00

1111 lines
41 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 ColorPickerModal from './ColorPickerModal'
import './Integrations.css'
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
const PROJECTS_API_URL = '/projects'
const PROJECT_COLOR_API_URL = '/project/color'
const PROJECT_MOVE_API_URL = '/project/move'
const PROJECT_CREATE_API_URL = '/project/create'
const PRIORITIES_CONFIRM_API_URL = '/priorities/confirm'
// Компонент экрана добавления проекта
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
/>
{validationError && (
<div className="mt-2 text-sm text-red-600">{validationError}</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 { authFetch } = useAuth()
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, onColorClick }) {
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, project.color)
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>
<button
onClick={(e) => {
e.stopPropagation()
if (onColorClick) {
onColorClick(project, e)
}
}}
className="w-3 h-3 rounded-full flex-shrink-0 hover:scale-125 transition-transform cursor-pointer border border-gray-300 hover:border-gray-500"
style={{ backgroundColor: projectColor }}
title="Выбрать цвет"
></button>
<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, onColorClick }) {
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}
onColorClick={onColorClick}
/>
))}
{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, onConfirmed, onClose }) {
const { authFetch } = useAuth()
const [projectsLoading, setProjectsLoading] = useState(false)
const [projectsError, setProjectsError] = useState(null)
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
const [toastMessage, setToastMessage] = useState(null)
const [isSaving, setIsSaving] = useState(false)
// Уведомляем родительский компонент об изменении состояния загрузки
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 [showColorPicker, setShowColorPicker] = useState(false) // Для модального окна выбора цвета
const [selectedProjectForColor, setSelectedProjectForColor] = useState(null) // Проект для выбора цвета
const scrollContainerRef = useRef(null)
const hasFetchedRef = useRef(false)
const lastRefreshTriggerRef = useRef(0)
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
const color = item?.color ?? null
return { id, name, priority: priorityValue, color }
})
.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, color: item.color }
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 handleSave = useCallback(async () => {
const assignments = buildAssignments()
const changes = []
assignments.forEach(({ id, priority }) => {
if (id) changes.push({ id, priority })
})
setIsSaving(true)
try {
const response = await authFetch(PRIORITIES_CONFIRM_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
})
if (!response.ok) {
const errText = await response.text().catch(() => '')
throw new Error(`Ошибка сохранения (${response.status})${errText ? ': ' + errText : ''}`)
}
if (onConfirmed) onConfirmed()
} catch (e) {
setToastMessage({ text: e.message || 'Ошибка сохранения', type: 'error' })
} finally {
setIsSaving(false)
}
}, [authFetch, buildAssignments, onConfirmed])
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 handleColorClick = (project, e) => {
e.stopPropagation()
setSelectedProjectForColor(project)
setShowColorPicker(true)
}
const handleColorSelect = async (color) => {
if (!selectedProjectForColor) return
try {
const projectId = selectedProjectForColor.id ?? selectedProjectForColor.name
const response = await authFetch(PROJECT_COLOR_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: projectId, color: color }),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(errorText || 'Ошибка при сохранении цвета')
}
setShowColorPicker(false)
setSelectedProjectForColor(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 h-full">
{(onNavigate || onClose) && (
<button
onClick={() => onClose ? onClose() : window.history.back()}
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 min-h-0 pt-[60px]"
style={{ touchAction: 'pan-y' }}
>
<SortableContext items={maxPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
<PrioritySlot
title="Максимальный приоритет (1 проект)"
projects={maxPriority}
allProjects={allProjects}
onMenuClick={handleMenuClick}
onColorClick={handleColorClick}
maxItems={1}
containerId="max"
/>
</SortableContext>
<SortableContext items={mediumPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
<PrioritySlot
title="Средний приоритет (2 проекта)"
projects={mediumPriority}
allProjects={allProjects}
onMenuClick={handleMenuClick}
onColorClick={handleColorClick}
maxItems={2}
containerId="medium"
/>
</SortableContext>
<SortableContext items={lowPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
<PrioritySlot
title="Остальные проекты"
projects={lowPriority}
allProjects={allProjects}
onMenuClick={handleMenuClick}
onColorClick={handleColorClick}
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, activeProject.color) }}
></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' })
}}
/>
)}
{/* Модальное окно выбора цвета */}
{showColorPicker && selectedProjectForColor && (
<ColorPickerModal
onClose={() => {
setShowColorPicker(false)
setSelectedProjectForColor(null)
}}
onColorSelect={handleColorSelect}
currentColor={selectedProjectForColor.color || getProjectColor(selectedProjectForColor.name, allProjects)}
/>
)}
<div style={{
position: 'sticky',
bottom: 0,
left: 0,
right: 0,
padding: '0.75rem 0',
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))',
background: 'linear-gradient(to top, white 60%, rgba(255,255,255,0))',
display: 'flex',
justifyContent: 'center',
}}>
<button
onClick={handleSave}
disabled={isSaving}
style={{
width: '100%',
maxWidth: '42rem',
padding: '0.875rem',
background: isSaving ? undefined : 'linear-gradient(to right, #10b981, #059669)',
backgroundColor: isSaving ? '#9ca3af' : undefined,
color: 'white',
border: 'none',
borderRadius: '0.5rem',
fontSize: '1rem',
fontWeight: 600,
cursor: isSaving ? 'not-allowed' : 'pointer',
opacity: isSaving ? 0.6 : 1,
transition: 'all 0.2s',
}}
>
{isSaving ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
{toastMessage && (
<Toast
message={toastMessage.text}
type={toastMessage.type}
onClose={() => setToastMessage(null)}
/>
)}
</div>
)
}
export default ProjectPriorityManager