Реализована возможность изменения проектов
- Добавлено поле deleted в таблицу projects (миграция 007) - Изменена иконка перехода на экран проектов (список вместо звезды) - Заменен крестик на троеточие в списке проектов - Добавлено модальное окно с кнопками 'Перенести' и 'Удалить' - Реализован экран переноса проекта с выбором существующего или созданием нового - Добавлены API endpoints: /project/move и /project/delete - При переносе проекта обновляются nodes и weekly_goals с обработкой конфликтов - При удалении проекта удаляются все связанные weekly_goals - Добавлена фильтрация удаленных проектов во всех SQL запросах - Обновлена materialized view для исключения удаленных проектов
This commit is contained in:
@@ -162,10 +162,13 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
|
||||
<button
|
||||
onClick={() => onNavigate('priorities')}
|
||||
className="flex-1 flex items-center justify-center px-4 bg-white hover:bg-indigo-50 text-indigo-600 hover:text-indigo-700 rounded-lg border border-indigo-200 hover:border-indigo-300 transition-all duration-200 shadow-sm hover:shadow-md"
|
||||
title="Приоритеты"
|
||||
title="Проекты"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||
<rect x="3" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="3" width="7" height="7"></rect>
|
||||
<rect x="14" y="14" width="7" height="7"></rect>
|
||||
<rect x="3" y="14" width="7" height="7"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -23,9 +23,134 @@ import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
||||
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
||||
const PROJECTS_API_URL = '/projects'
|
||||
const PRIORITY_UPDATE_API_URL = '/project/priority'
|
||||
const PROJECT_MOVE_API_URL = '/project/move'
|
||||
|
||||
// Компонент экрана переноса проекта
|
||||
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
|
||||
const response = await fetch(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)
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// Компонент для сортируемого элемента проекта
|
||||
function SortableProjectItem({ project, index, allProjects, onRemove }) {
|
||||
function SortableProjectItem({ project, index, allProjects, onMenuClick }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -75,15 +200,16 @@ function SortableProjectItem({ project, index, allProjects, onRemove }) {
|
||||
></div>
|
||||
<span className="font-semibold text-gray-800">{project.name}</span>
|
||||
</div>
|
||||
{onRemove && (
|
||||
{onMenuClick && (
|
||||
<button
|
||||
onClick={() => onRemove(project.name)}
|
||||
className="ml-2 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="Убрать из этого слота"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onMenuClick(project, e)
|
||||
}}
|
||||
className="ml-2 text-gray-400 hover:text-gray-600 transition-colors text-xl font-bold"
|
||||
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>
|
||||
@@ -114,7 +240,7 @@ function DroppableSlot({ containerId, isEmpty, maxItems, currentCount }) {
|
||||
}
|
||||
|
||||
// Компонент для слота приоритета
|
||||
function PrioritySlot({ title, projects, allProjects, onRemove, maxItems = null, containerId }) {
|
||||
function PrioritySlot({ title, projects, allProjects, onMenuClick, maxItems = null, containerId }) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<div className="text-sm font-semibold text-gray-600 mb-2">{title}</div>
|
||||
@@ -128,7 +254,7 @@ function PrioritySlot({ title, projects, allProjects, onRemove, maxItems = null,
|
||||
project={project}
|
||||
index={index}
|
||||
allProjects={allProjects}
|
||||
onRemove={onRemove}
|
||||
onMenuClick={onMenuClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -159,6 +285,8 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
||||
const [mediumPriority, setMediumPriority] = useState([])
|
||||
const [lowPriority, setLowPriority] = useState([])
|
||||
const [activeId, setActiveId] = useState(null)
|
||||
const [selectedProject, setSelectedProject] = useState(null) // Для модального окна
|
||||
const [showMoveScreen, setShowMoveScreen] = useState(false) // Для экрана переноса
|
||||
|
||||
|
||||
const scrollContainerRef = useRef(null)
|
||||
@@ -580,30 +708,42 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
||||
})
|
||||
}
|
||||
|
||||
const handleRemove = (projectName, container) => {
|
||||
const sourceList =
|
||||
container === 'max'
|
||||
? maxPriority
|
||||
: container === 'medium'
|
||||
? mediumPriority
|
||||
: lowPriority
|
||||
const handleMenuClick = (project, e) => {
|
||||
e.stopPropagation()
|
||||
setSelectedProject(project)
|
||||
}
|
||||
|
||||
const project = sourceList.find(p => p.name === projectName)
|
||||
if (!project) return
|
||||
const handleMove = () => {
|
||||
if (!selectedProject) return
|
||||
setShowMoveScreen(true)
|
||||
}
|
||||
|
||||
const projectId = project.id ?? project.name
|
||||
if (projectId) {
|
||||
skipNextEffectRef.current = true
|
||||
sendPriorityChanges([{ id: projectId, priority: null }])
|
||||
const handleDelete = async () => {
|
||||
if (!selectedProject) return
|
||||
|
||||
try {
|
||||
const projectId = selectedProject.id ?? selectedProject.name
|
||||
const response = await fetch(`/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)
|
||||
setProjectsError(error.message || 'Ошибка удаления проекта')
|
||||
}
|
||||
}
|
||||
|
||||
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 closeModal = () => {
|
||||
setSelectedProject(null)
|
||||
}
|
||||
|
||||
const allItems = [
|
||||
@@ -663,7 +803,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
||||
title="Максимальный приоритет (1 проект)"
|
||||
projects={maxPriority}
|
||||
allProjects={allProjects}
|
||||
onRemove={(name) => handleRemove(name, 'max')}
|
||||
onMenuClick={handleMenuClick}
|
||||
maxItems={1}
|
||||
containerId="max"
|
||||
/>
|
||||
@@ -674,7 +814,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
||||
title="Средний приоритет (2 проекта)"
|
||||
projects={mediumPriority}
|
||||
allProjects={allProjects}
|
||||
onRemove={(name) => handleRemove(name, 'medium')}
|
||||
onMenuClick={handleMenuClick}
|
||||
maxItems={2}
|
||||
containerId="medium"
|
||||
/>
|
||||
@@ -685,6 +825,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
||||
title="Остальные проекты"
|
||||
projects={lowPriority}
|
||||
allProjects={allProjects}
|
||||
onMenuClick={handleMenuClick}
|
||||
containerId="low"
|
||||
/>
|
||||
</SortableContext>
|
||||
@@ -716,6 +857,52 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
||||
: 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()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user