2025-12-29 20:01:55 +03:00
|
|
|
|
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'
|
2026-01-01 18:21:18 +03:00
|
|
|
|
import { useAuth } from './auth/AuthContext'
|
2026-01-08 00:02:06 +03:00
|
|
|
|
import './Integrations.css'
|
2025-12-29 20:01:55 +03:00
|
|
|
|
|
|
|
|
|
|
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
|
|
|
|
|
const PROJECTS_API_URL = '/projects'
|
|
|
|
|
|
const PRIORITY_UPDATE_API_URL = '/project/priority'
|
2025-12-29 21:31:43 +03:00
|
|
|
|
const PROJECT_MOVE_API_URL = '/project/move'
|
2026-01-02 16:09:16 +03:00
|
|
|
|
const PROJECT_CREATE_API_URL = '/project/create'
|
|
|
|
|
|
|
|
|
|
|
|
// Компонент экрана добавления проекта
|
|
|
|
|
|
function AddProjectScreen({ onClose, onSuccess }) {
|
|
|
|
|
|
const { authFetch } = useAuth()
|
|
|
|
|
|
const [projectName, setProjectName] = useState('')
|
|
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
|
|
|
|
const [error, setError] = useState(null)
|
|
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async () => {
|
|
|
|
|
|
if (!projectName.trim()) {
|
|
|
|
|
|
setError('Введите название проекта')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setIsSubmitting(true)
|
|
|
|
|
|
setError(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)
|
|
|
|
|
|
setError(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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2025-12-29 21:31:43 +03:00
|
|
|
|
|
|
|
|
|
|
// Компонент экрана переноса проекта
|
|
|
|
|
|
function MoveProjectScreen({ project, allProjects, onClose, onSuccess }) {
|
|
|
|
|
|
const [newProjectName, setNewProjectName] = useState('')
|
|
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
|
|
|
|
const [error, setError] = useState(null)
|
|
|
|
|
|
|
|
|
|
|
|
const handleProjectClick = (projectName) => {
|
|
|
|
|
|
setNewProjectName(projectName)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleSubmit = async () => {
|
|
|
|
|
|
if (!newProjectName.trim()) {
|
|
|
|
|
|
setError('Введите название проекта')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setIsSubmitting(true)
|
|
|
|
|
|
setError(null)
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const projectId = project.id ?? project.name
|
2026-01-01 18:21:18 +03:00
|
|
|
|
const response = await authFetch(PROJECT_MOVE_API_URL, {
|
2025-12-29 21:31:43 +03:00
|
|
|
|
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)
|
|
|
|
|
|
setError(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"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{error && (
|
|
|
|
|
|
<div className="mt-2 text-sm text-red-600">{error}</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2025-12-29 20:01:55 +03:00
|
|
|
|
|
|
|
|
|
|
// Компонент для сортируемого элемента проекта
|
2025-12-29 21:31:43 +03:00
|
|
|
|
function SortableProjectItem({ project, index, allProjects, onMenuClick }) {
|
2025-12-29 20:01:55 +03:00
|
|
|
|
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}
|
2026-01-02 18:11:57 +03:00
|
|
|
|
style={style}
|
2025-12-29 20:01:55 +03:00
|
|
|
|
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>
|
2025-12-29 21:31:43 +03:00
|
|
|
|
{onMenuClick && (
|
2025-12-29 20:01:55 +03:00
|
|
|
|
<button
|
2025-12-29 21:31:43 +03:00
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
|
onMenuClick(project, e)
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="ml-2 text-gray-400 hover:text-gray-600 transition-colors text-xl font-bold"
|
|
|
|
|
|
title="Меню"
|
2025-12-29 20:01:55 +03:00
|
|
|
|
>
|
2025-12-29 21:31:43 +03:00
|
|
|
|
⋮
|
2025-12-29 20:01:55 +03:00
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Компонент для слота приоритета
|
2026-01-02 16:09:16 +03:00
|
|
|
|
function PrioritySlot({ title, projects, allProjects, onMenuClick, maxItems = null, containerId, onAddClick }) {
|
2025-12-29 20:01:55 +03:00
|
|
|
|
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}
|
2025-12-29 21:31:43 +03:00
|
|
|
|
onMenuClick={onMenuClick}
|
2025-12-29 20:01:55 +03:00
|
|
|
|
/>
|
|
|
|
|
|
))}
|
2026-01-02 16:09:16 +03:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2025-12-29 20:01:55 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) {
|
2026-01-01 18:21:18 +03:00
|
|
|
|
const { authFetch } = useAuth()
|
2025-12-29 20:01:55 +03:00
|
|
|
|
const [projectsLoading, setProjectsLoading] = useState(false)
|
|
|
|
|
|
const [projectsError, setProjectsError] = useState(null)
|
|
|
|
|
|
const [hasDataCache, setHasDataCache] = 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)
|
2025-12-29 21:31:43 +03:00
|
|
|
|
const [selectedProject, setSelectedProject] = useState(null) // Для модального окна
|
|
|
|
|
|
const [showMoveScreen, setShowMoveScreen] = useState(false) // Для экрана переноса
|
2026-01-02 16:09:16 +03:00
|
|
|
|
const [showAddScreen, setShowAddScreen] = useState(false) // Для экрана добавления
|
2025-12-29 20:01:55 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: {
|
2026-01-02 18:11:57 +03:00
|
|
|
|
distance: 15, // Увеличиваем расстояние для активации, чтобы дать больше времени для скролла
|
2025-12-29 20:01:55 +03:00
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-01-01 18:21:18 +03:00
|
|
|
|
const response = await authFetch(PROJECTS_API_URL)
|
2025-12-29 20:01:55 +03:00
|
|
|
|
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 {
|
2026-01-01 18:21:18 +03:00
|
|
|
|
await authFetch(PRIORITY_UPDATE_API_URL, {
|
2025-12-29 20:01:55 +03:00
|
|
|
|
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]
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-29 21:31:43 +03:00
|
|
|
|
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
|
2026-01-01 18:21:18 +03:00
|
|
|
|
const response = await authFetch(`/project/delete`, {
|
2025-12-29 21:31:43 +03:00
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
|
body: JSON.stringify({ id: projectId }),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
|
throw new Error('Ошибка при удалении проекта')
|
|
|
|
|
|
}
|
2025-12-29 20:01:55 +03:00
|
|
|
|
|
2025-12-29 21:31:43 +03:00
|
|
|
|
setSelectedProject(null)
|
|
|
|
|
|
// Обновляем список проектов
|
|
|
|
|
|
fetchProjects()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Ошибка удаления проекта:', error)
|
|
|
|
|
|
setProjectsError(error.message || 'Ошибка удаления проекта')
|
2025-12-29 20:01:55 +03:00
|
|
|
|
}
|
2025-12-29 21:31:43 +03:00
|
|
|
|
}
|
2025-12-29 20:01:55 +03:00
|
|
|
|
|
2025-12-29 21:31:43 +03:00
|
|
|
|
const closeModal = () => {
|
|
|
|
|
|
setSelectedProject(null)
|
2025-12-29 20:01:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
2026-01-09 14:40:45 +03:00
|
|
|
|
<div className="max-w-2xl mx-auto flex flex-col max-h-[calc(100vh-11rem)] pt-[60px]">
|
2025-12-29 20:01:55 +03:00
|
|
|
|
{onNavigate && (
|
2026-01-08 00:02:06 +03:00
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => onNavigate('current')}
|
|
|
|
|
|
className="close-x-button"
|
|
|
|
|
|
title="Закрыть"
|
|
|
|
|
|
>
|
|
|
|
|
|
✕
|
|
|
|
|
|
</button>
|
2025-12-29 20:01:55 +03:00
|
|
|
|
)}
|
|
|
|
|
|
{projectsError && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) && (
|
2026-01-02 17:52:54 +03:00
|
|
|
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 shadow-sm flex-shrink-0">
|
2025-12-29 20:01:55 +03:00
|
|
|
|
<div className="font-semibold">Не удалось загрузить проекты</div>
|
|
|
|
|
|
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
|
|
|
|
|
|
<span className="text-red-600">{projectsError}</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => fetchProjects()}
|
|
|
|
|
|
className="rounded-md bg-red-600 px-3 py-1 text-white shadow hover:bg-red-700 transition"
|
|
|
|
|
|
>
|
|
|
|
|
|
Повторить
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{projectsLoading && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) ? (
|
2026-01-11 15:32:31 +03:00
|
|
|
|
<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>
|
2025-12-29 20:01:55 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<DndContext
|
|
|
|
|
|
sensors={sensors}
|
|
|
|
|
|
collisionDetection={closestCenter}
|
|
|
|
|
|
onDragStart={handleDragStart}
|
|
|
|
|
|
onDragEnd={handleDragEnd}
|
|
|
|
|
|
onDragCancel={handleDragCancel}
|
|
|
|
|
|
>
|
2026-01-02 18:11:57 +03:00
|
|
|
|
<div
|
|
|
|
|
|
className="space-y-6 overflow-y-auto flex-1"
|
|
|
|
|
|
style={{ minHeight: 0, touchAction: 'pan-y' }}
|
|
|
|
|
|
>
|
2025-12-29 20:01:55 +03:00
|
|
|
|
<SortableContext items={maxPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
|
|
|
|
|
|
<PrioritySlot
|
|
|
|
|
|
title="Максимальный приоритет (1 проект)"
|
|
|
|
|
|
projects={maxPriority}
|
|
|
|
|
|
allProjects={allProjects}
|
2025-12-29 21:31:43 +03:00
|
|
|
|
onMenuClick={handleMenuClick}
|
2025-12-29 20:01:55 +03:00
|
|
|
|
maxItems={1}
|
|
|
|
|
|
containerId="max"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SortableContext>
|
|
|
|
|
|
|
|
|
|
|
|
<SortableContext items={mediumPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
|
|
|
|
|
|
<PrioritySlot
|
|
|
|
|
|
title="Средний приоритет (2 проекта)"
|
|
|
|
|
|
projects={mediumPriority}
|
|
|
|
|
|
allProjects={allProjects}
|
2025-12-29 21:31:43 +03:00
|
|
|
|
onMenuClick={handleMenuClick}
|
2025-12-29 20:01:55 +03:00
|
|
|
|
maxItems={2}
|
|
|
|
|
|
containerId="medium"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</SortableContext>
|
|
|
|
|
|
|
|
|
|
|
|
<SortableContext items={lowPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
|
|
|
|
|
|
<PrioritySlot
|
|
|
|
|
|
title="Остальные проекты"
|
|
|
|
|
|
projects={lowPriority}
|
|
|
|
|
|
allProjects={allProjects}
|
2025-12-29 21:31:43 +03:00
|
|
|
|
onMenuClick={handleMenuClick}
|
2025-12-29 20:01:55 +03:00
|
|
|
|
containerId="low"
|
2026-01-02 16:09:16 +03:00
|
|
|
|
onAddClick={() => setShowAddScreen(true)}
|
2025-12-29 20:01:55 +03:00
|
|
|
|
/>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
)}
|
2025-12-29 21:31:43 +03:00
|
|
|
|
|
|
|
|
|
|
{/* Модальное окно для действий с проектом */}
|
|
|
|
|
|
{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()
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-01-02 16:09:16 +03:00
|
|
|
|
|
|
|
|
|
|
{/* Экран добавления проекта */}
|
|
|
|
|
|
{showAddScreen && (
|
|
|
|
|
|
<AddProjectScreen
|
|
|
|
|
|
onClose={() => setShowAddScreen(false)}
|
|
|
|
|
|
onSuccess={() => {
|
|
|
|
|
|
setShowAddScreen(false)
|
|
|
|
|
|
fetchProjects()
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2025-12-29 20:01:55 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default ProjectPriorityManager
|
|
|
|
|
|
|