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 ( +