Initial commit

This commit is contained in:
poignatov
2025-12-29 20:01:55 +03:00
commit 4f8a793377
63 changed files with 13655 additions and 0 deletions

View File

@@ -0,0 +1,724 @@
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'
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
const PROJECTS_API_URL = '/projects'
const PRIORITY_UPDATE_API_URL = '/project/priority'
// Компонент для сортируемого элемента проекта
function SortableProjectItem({ project, index, allProjects, onRemove }) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: project.name })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const projectColor = getProjectColor(project.name, allProjects)
return (
<div
ref={setNodeRef}
data-id={project.name}
style={{ ...style, touchAction: 'none' }}
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>
{onRemove && (
<button
onClick={() => onRemove(project.name)}
className="ml-2 text-gray-400 hover:text-red-500 transition-colors"
title="Убрать из этого слота"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</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, onRemove, maxItems = null, containerId }) {
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}
onRemove={onRemove}
/>
))}
</div>
</div>
)
}
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) {
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)
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: 10, // Активация только после перемещения на 10px
},
}),
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 fetch(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 fetch(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 handleRemove = (projectName, container) => {
const sourceList =
container === 'max'
? maxPriority
: container === 'medium'
? mediumPriority
: lowPriority
const project = sourceList.find(p => p.name === projectName)
if (!project) return
const projectId = project.id ?? project.name
if (projectId) {
skipNextEffectRef.current = true
sendPriorityChanges([{ id: projectId, priority: null }])
}
if (container === 'max') {
setMaxPriority(prev => prev.filter(p => p.name !== projectName))
} else if (container === 'medium') {
setMediumPriority(prev => prev.filter(p => p.name !== projectName))
}
moveProjectToLow(project)
}
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-4xl mx-auto">
{onNavigate && (
<div className="flex justify-end mb-4">
<button
onClick={() => onNavigate('current')}
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>
)}
{projectsError && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 shadow-sm">
<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) ? (
<div className="rounded-lg border border-gray-200 bg-white p-4 text-center text-gray-600 shadow-sm">
Загружаем проекты...
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="space-y-6">
<SortableContext items={maxPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
<PrioritySlot
title="Максимальный приоритет (1 проект)"
projects={maxPriority}
allProjects={allProjects}
onRemove={(name) => handleRemove(name, 'max')}
maxItems={1}
containerId="max"
/>
</SortableContext>
<SortableContext items={mediumPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
<PrioritySlot
title="Средний приоритет (2 проекта)"
projects={mediumPriority}
allProjects={allProjects}
onRemove={(name) => handleRemove(name, 'medium')}
maxItems={2}
containerId="medium"
/>
</SortableContext>
<SortableContext items={lowPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
<PrioritySlot
title="Остальные проекты"
projects={lowPriority}
allProjects={allProjects}
containerId="low"
/>
</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>
)}
</div>
)
}
export default ProjectPriorityManager