From fc83bed1a3dba0a46359eb07214872bb2600e5b9 Mon Sep 17 00:00:00 2001 From: poignatov Date: Mon, 29 Dec 2025 21:31:43 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлено поле deleted в таблицу projects (миграция 007) - Изменена иконка перехода на экран проектов (список вместо звезды) - Заменен крестик на троеточие в списке проектов - Добавлено модальное окно с кнопками 'Перенести' и 'Удалить' - Реализован экран переноса проекта с выбором существующего или созданием нового - Добавлены API endpoints: /project/move и /project/delete - При переносе проекта обновляются nodes и weekly_goals с обработкой конфликтов - При удалении проекта удаляются все связанные weekly_goals - Добавлена фильтрация удаленных проектов во всех SQL запросах - Обновлена materialized view для исключения удаленных проектов --- play-life-backend/main.go | 255 +++++++++++++++++- .../006_fix_weekly_report_mv_structure.sql | 2 + .../007_add_deleted_to_projects.sql | 13 + play-life-backend/start_backend.sh | 0 play-life-web/src/components/CurrentWeek.jsx | 7 +- .../src/components/ProjectPriorityManager.jsx | 251 ++++++++++++++--- 6 files changed, 486 insertions(+), 42 deletions(-) create mode 100644 play-life-backend/migrations/007_add_deleted_to_projects.sql mode change 100644 => 100755 play-life-backend/start_backend.sh diff --git a/play-life-backend/main.go b/play-life-backend/main.go index c419935..c803598 100644 --- a/play-life-backend/main.go +++ b/play-life-backend/main.go @@ -1832,6 +1832,23 @@ func (a *App) initPlayLifeDB() error { return fmt.Errorf("failed to create projects table: %w", err) } + // Добавляем колонку deleted, если её нет (для существующих баз) + alterProjectsTable := ` + ALTER TABLE projects + ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE + ` + if _, err := a.DB.Exec(alterProjectsTable); err != nil { + log.Printf("Warning: Failed to add deleted column to projects table: %v", err) + } + + // Создаем индекс на deleted + createProjectsDeletedIndex := ` + CREATE INDEX IF NOT EXISTS idx_projects_deleted ON projects(deleted) + ` + if _, err := a.DB.Exec(createProjectsDeletedIndex); err != nil { + log.Printf("Warning: Failed to create projects deleted index: %v", err) + } + if _, err := a.DB.Exec(createEntriesTable); err != nil { return fmt.Errorf("failed to create entries table: %w", err) } @@ -1882,6 +1899,8 @@ func (a *App) initPlayLifeDB() error { 1, 2, 3 ) agg ON p.id = agg.project_id + WHERE + p.deleted = FALSE ORDER BY p.id, agg.report_year, agg.report_week ` @@ -1971,6 +1990,7 @@ func (a *App) getWeeklyStatsData() (*WeeklyStatsResponse, error) { -- Фильтруем ТОЛЬКО по целям текущего года и недели wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER + AND p.deleted = FALSE ORDER BY total_score DESC ` @@ -2339,6 +2359,8 @@ func main() { r.HandleFunc("/daily-report/trigger", app.dailyReportTriggerHandler).Methods("POST", "OPTIONS") r.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS") r.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "OPTIONS") + r.HandleFunc("/project/move", app.moveProjectHandler).Methods("POST", "OPTIONS") + r.HandleFunc("/project/delete", app.deleteProjectHandler).Methods("POST", "OPTIONS") r.HandleFunc("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS") r.HandleFunc("/admin", app.adminHandler).Methods("GET") r.HandleFunc("/admin.html", app.adminHandler).Methods("GET") @@ -2794,10 +2816,10 @@ func (a *App) insertMessageData(entryText string, createdDate string, nodes []Pr // Вставляем проекты for projectName := range projectNames { _, err := tx.Exec(` - INSERT INTO projects (name) - VALUES ($1) + INSERT INTO projects (name, deleted) + VALUES ($1, FALSE) ON CONFLICT (name) DO UPDATE - SET name = EXCLUDED.name + SET name = EXCLUDED.name, deleted = FALSE `, projectName) if err != nil { return fmt.Errorf("failed to upsert project %s: %w", projectName, err) @@ -2818,10 +2840,10 @@ func (a *App) insertMessageData(entryText string, createdDate string, nodes []Pr // 3. Вставляем nodes for _, node := range nodes { _, err := tx.Exec(` - INSERT INTO nodes (project_id, entry_id, score) - SELECT p.id, $1, $2 - FROM projects p - WHERE p.name = $3 + INSERT INTO nodes (project_id, entry_id, score) + SELECT p.id, $1, $2 + FROM projects p + WHERE p.name = $3 AND p.deleted = FALSE `, entryID, node.Score, node.Project) if err != nil { return fmt.Errorf("failed to insert node for project %s: %w", node.Project, err) @@ -2892,6 +2914,7 @@ func (a *App) setupWeeklyGoals() error { FROM projects p CROSS JOIN current_info ci LEFT JOIN goal_metrics gm ON p.id = gm.project_id + WHERE p.deleted = FALSE ON CONFLICT (project_id, goal_year, goal_week) DO UPDATE SET min_goal_score = EXCLUDED.min_goal_score, @@ -2931,6 +2954,7 @@ func (a *App) sendWeeklyGoalsTelegramMessage() error { WHERE wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER + AND p.deleted = FALSE ORDER BY p.name ` @@ -3056,6 +3080,7 @@ func (a *App) weeklyGoalsSetupHandler(w http.ResponseWriter, r *http.Request) { WHERE wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER + AND p.deleted = FALSE ORDER BY p.name ` @@ -3192,6 +3217,8 @@ func (a *App) recreateMaterializedViewHandler(w http.ResponseWriter, r *http.Req 1, 2, 3 ) agg ON p.id = agg.project_id + WHERE + p.deleted = FALSE ORDER BY p.id, agg.report_year, agg.report_week ` @@ -3232,6 +3259,8 @@ func (a *App) getProjectsHandler(w http.ResponseWriter, r *http.Request) { priority FROM projects + WHERE + deleted = FALSE ORDER BY priority ASC NULLS LAST, project_name @@ -3442,6 +3471,214 @@ func (a *App) setProjectPriorityHandler(w http.ResponseWriter, r *http.Request) }) } +type ProjectMoveRequest struct { + ID int `json:"id"` + NewName string `json:"new_name"` +} + +type ProjectDeleteRequest struct { + ID int `json:"id"` +} + +func (a *App) moveProjectHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + var req ProjectMoveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Error decoding move project request: %v", err) + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.NewName == "" { + sendErrorWithCORS(w, "new_name is required", http.StatusBadRequest) + return + } + + // Начинаем транзакцию + tx, err := a.DB.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Ищем проект с таким именем + var targetProjectID int + err = tx.QueryRow(` + SELECT id FROM projects WHERE name = $1 AND deleted = FALSE + `, req.NewName).Scan(&targetProjectID) + + var finalProjectID int + if err == sql.ErrNoRows { + // Проект не найден - создаем новый + err = tx.QueryRow(` + INSERT INTO projects (name, deleted) + VALUES ($1, FALSE) + RETURNING id + `, req.NewName).Scan(&finalProjectID) + if err != nil { + log.Printf("Error creating new project: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error creating new project: %v", err), http.StatusInternalServerError) + return + } + } else if err != nil { + log.Printf("Error querying target project: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error querying target project: %v", err), http.StatusInternalServerError) + return + } else { + // Проект найден - используем его ID + finalProjectID = targetProjectID + } + + // Обновляем все nodes с project_id на целевой + _, err = tx.Exec(` + UPDATE nodes + SET project_id = $1 + WHERE project_id = $2 + `, finalProjectID, req.ID) + if err != nil { + log.Printf("Error updating nodes: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error updating nodes: %v", err), http.StatusInternalServerError) + return + } + + // Обновляем weekly_goals + // Сначала удаляем записи старого проекта, которые конфликтуют с записями целевого проекта + // (если у целевого проекта уже есть запись для той же недели) + _, err = tx.Exec(` + DELETE FROM weekly_goals + WHERE project_id = $1 + AND EXISTS ( + SELECT 1 + FROM weekly_goals wg2 + WHERE wg2.project_id = $2 + AND wg2.goal_year = weekly_goals.goal_year + AND wg2.goal_week = weekly_goals.goal_week + ) + `, req.ID, finalProjectID) + if err != nil { + log.Printf("Error deleting conflicting weekly_goals: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error deleting conflicting weekly_goals: %v", err), http.StatusInternalServerError) + return + } + + // Теперь обновляем оставшиеся записи (те, которые не конфликтуют) + _, err = tx.Exec(` + UPDATE weekly_goals + SET project_id = $1 + WHERE project_id = $2 + `, finalProjectID, req.ID) + if err != nil { + log.Printf("Error updating weekly_goals: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error updating weekly_goals: %v", err), http.StatusInternalServerError) + return + } + + // Помечаем старый проект как удаленный + _, err = tx.Exec(` + UPDATE projects + SET deleted = TRUE + WHERE id = $1 + `, req.ID) + if err != nil { + log.Printf("Error marking project as deleted: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error marking project as deleted: %v", err), http.StatusInternalServerError) + return + } + + // Коммитим транзакцию + if err := tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) + return + } + + // Обновляем materialized view + _, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") + if err != nil { + log.Printf("Warning: Failed to refresh materialized view: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Project moved successfully", + "project_id": finalProjectID, + }) +} + +func (a *App) deleteProjectHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == "OPTIONS" { + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + } + setCORSHeaders(w) + + var req ProjectDeleteRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("Error decoding delete project request: %v", err) + sendErrorWithCORS(w, "Invalid request body", http.StatusBadRequest) + return + } + + // Начинаем транзакцию + tx, err := a.DB.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error beginning transaction: %v", err), http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Удаляем все записи weekly_goals для этого проекта + _, err = tx.Exec(` + DELETE FROM weekly_goals + WHERE project_id = $1 + `, req.ID) + if err != nil { + log.Printf("Error deleting weekly_goals: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error deleting weekly_goals: %v", err), http.StatusInternalServerError) + return + } + + // Помечаем проект как удаленный + _, err = tx.Exec(` + UPDATE projects + SET deleted = TRUE + WHERE id = $1 + `, req.ID) + if err != nil { + log.Printf("Error marking project as deleted: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error marking project as deleted: %v", err), http.StatusInternalServerError) + return + } + + // Коммитим транзакцию + if err := tx.Commit(); err != nil { + log.Printf("Error committing transaction: %v", err) + sendErrorWithCORS(w, fmt.Sprintf("Error committing transaction: %v", err), http.StatusInternalServerError) + return + } + + // Обновляем materialized view + _, err = a.DB.Exec("REFRESH MATERIALIZED VIEW weekly_report_mv") + if err != nil { + log.Printf("Warning: Failed to refresh materialized view: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Project deleted successfully", + }) +} + func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) { // Логирование входящего запроса log.Printf("=== Todoist Webhook Request ===") @@ -3700,7 +3937,9 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) { JOIN projects p -- Присоединяем имя проекта, используя ID из той таблицы, где он не NULL - ON p.id = COALESCE(wr.project_id, wg.project_id) + ON p.id = COALESCE(wr.project_id, wg.project_id) + WHERE + p.deleted = FALSE ORDER BY report_year DESC, report_week DESC, diff --git a/play-life-backend/migrations/006_fix_weekly_report_mv_structure.sql b/play-life-backend/migrations/006_fix_weekly_report_mv_structure.sql index 9d11af3..90ddb78 100644 --- a/play-life-backend/migrations/006_fix_weekly_report_mv_structure.sql +++ b/play-life-backend/migrations/006_fix_weekly_report_mv_structure.sql @@ -34,6 +34,8 @@ LEFT JOIN 1, 2, 3 ) agg ON p.id = agg.project_id +WHERE + p.deleted = FALSE ORDER BY p.id, agg.report_year, agg.report_week WITH DATA; diff --git a/play-life-backend/migrations/007_add_deleted_to_projects.sql b/play-life-backend/migrations/007_add_deleted_to_projects.sql new file mode 100644 index 0000000..fd9f824 --- /dev/null +++ b/play-life-backend/migrations/007_add_deleted_to_projects.sql @@ -0,0 +1,13 @@ +-- Migration: Add deleted field to projects table +-- This script adds a deleted boolean field to mark projects as deleted (soft delete) + +-- Add deleted column to projects table +ALTER TABLE projects +ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE; + +-- Create index on deleted column for better query performance +CREATE INDEX IF NOT EXISTS idx_projects_deleted ON projects(deleted); + +-- Add comment for documentation +COMMENT ON COLUMN projects.deleted IS 'Soft delete flag: TRUE if project is deleted, FALSE otherwise'; + diff --git a/play-life-backend/start_backend.sh b/play-life-backend/start_backend.sh old mode 100644 new mode 100755 diff --git a/play-life-web/src/components/CurrentWeek.jsx b/play-life-web/src/components/CurrentWeek.jsx index bfbedd0..53456be 100644 --- a/play-life-web/src/components/CurrentWeek.jsx +++ b/play-life-web/src/components/CurrentWeek.jsx @@ -162,10 +162,13 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject diff --git a/play-life-web/src/components/ProjectPriorityManager.jsx b/play-life-web/src/components/ProjectPriorityManager.jsx index 165824d..9133d89 100644 --- a/play-life-web/src/components/ProjectPriorityManager.jsx +++ b/play-life-web/src/components/ProjectPriorityManager.jsx @@ -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 ( +
+
+ {/* Заголовок с кнопкой закрытия */} +
+ +
+ + {/* Контент */} +
+ {/* Текущее имя проекта */} +
+
Текущее имя проекта
+
{project.name}
+
+ + {/* Стрелочка вниз */} +
+ + + +
+ + {/* Поле ввода */} +
+ setNewProjectName(e.target.value)} + placeholder="Введите новое название проекта" + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent" + /> + {error && ( +
{error}
+ )} +
+ + {/* Список проектов */} + {allProjects.length > 0 && ( +
+
Выберите существующий проект:
+
+ {allProjects.map((p) => ( + + ))} +
+
+ )} +
+ + {/* Кнопка подтверждения (прибита к низу) */} +
+ +
+
+
+ ) +} // Компонент для сортируемого элемента проекта -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 }) { > {project.name} - {onRemove && ( + {onMenuClick && ( )} @@ -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 (
{title}
@@ -128,7 +254,7 @@ function PrioritySlot({ title, projects, allProjects, onRemove, maxItems = null, project={project} index={index} allProjects={allProjects} - onRemove={onRemove} + onMenuClick={onMenuClick} /> ))}
@@ -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" /> @@ -716,6 +857,52 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, : null} )} + + {/* Модальное окно для действий с проектом */} + {selectedProject && !showMoveScreen && ( +
+
e.stopPropagation()}> +
+

{selectedProject.name}

+
+
+ + +
+
+
+ )} + + {/* Экран переноса проекта */} + {showMoveScreen && selectedProject && ( + { + const projectId = p.id ?? p.name + const selectedId = selectedProject.id ?? selectedProject.name + return projectId !== selectedId && p.name !== selectedProject.name + })} + onClose={() => { + setShowMoveScreen(false) + setSelectedProject(null) + }} + onSuccess={() => { + setShowMoveScreen(false) + setSelectedProject(null) + fetchProjects() + }} + /> + )} ) }