Реализована возможность изменения проектов
- Добавлено поле deleted в таблицу projects (миграция 007) - Изменена иконка перехода на экран проектов (список вместо звезды) - Заменен крестик на троеточие в списке проектов - Добавлено модальное окно с кнопками 'Перенести' и 'Удалить' - Реализован экран переноса проекта с выбором существующего или созданием нового - Добавлены API endpoints: /project/move и /project/delete - При переносе проекта обновляются nodes и weekly_goals с обработкой конфликтов - При удалении проекта удаляются все связанные weekly_goals - Добавлена фильтрация удаленных проектов во всех SQL запросах - Обновлена materialized view для исключения удаленных проектов
This commit is contained in:
@@ -1832,6 +1832,23 @@ func (a *App) initPlayLifeDB() error {
|
|||||||
return fmt.Errorf("failed to create projects table: %w", err)
|
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 {
|
if _, err := a.DB.Exec(createEntriesTable); err != nil {
|
||||||
return fmt.Errorf("failed to create entries table: %w", err)
|
return fmt.Errorf("failed to create entries table: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1882,6 +1899,8 @@ func (a *App) initPlayLifeDB() error {
|
|||||||
1, 2, 3
|
1, 2, 3
|
||||||
) agg
|
) agg
|
||||||
ON p.id = agg.project_id
|
ON p.id = agg.project_id
|
||||||
|
WHERE
|
||||||
|
p.deleted = FALSE
|
||||||
ORDER BY
|
ORDER BY
|
||||||
p.id, agg.report_year, agg.report_week
|
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
|
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||||
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||||||
|
AND p.deleted = FALSE
|
||||||
ORDER BY
|
ORDER BY
|
||||||
total_score DESC
|
total_score DESC
|
||||||
`
|
`
|
||||||
@@ -2339,6 +2359,8 @@ func main() {
|
|||||||
r.HandleFunc("/daily-report/trigger", app.dailyReportTriggerHandler).Methods("POST", "OPTIONS")
|
r.HandleFunc("/daily-report/trigger", app.dailyReportTriggerHandler).Methods("POST", "OPTIONS")
|
||||||
r.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS")
|
r.HandleFunc("/projects", app.getProjectsHandler).Methods("GET", "OPTIONS")
|
||||||
r.HandleFunc("/project/priority", app.setProjectPriorityHandler).Methods("POST", "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("/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b", app.getFullStatisticsHandler).Methods("GET", "OPTIONS")
|
||||||
r.HandleFunc("/admin", app.adminHandler).Methods("GET")
|
r.HandleFunc("/admin", app.adminHandler).Methods("GET")
|
||||||
r.HandleFunc("/admin.html", 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 {
|
for projectName := range projectNames {
|
||||||
_, err := tx.Exec(`
|
_, err := tx.Exec(`
|
||||||
INSERT INTO projects (name)
|
INSERT INTO projects (name, deleted)
|
||||||
VALUES ($1)
|
VALUES ($1, FALSE)
|
||||||
ON CONFLICT (name) DO UPDATE
|
ON CONFLICT (name) DO UPDATE
|
||||||
SET name = EXCLUDED.name
|
SET name = EXCLUDED.name, deleted = FALSE
|
||||||
`, projectName)
|
`, projectName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to upsert project %s: %w", projectName, err)
|
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
|
// 3. Вставляем nodes
|
||||||
for _, node := range nodes {
|
for _, node := range nodes {
|
||||||
_, err := tx.Exec(`
|
_, err := tx.Exec(`
|
||||||
INSERT INTO nodes (project_id, entry_id, score)
|
INSERT INTO nodes (project_id, entry_id, score)
|
||||||
SELECT p.id, $1, $2
|
SELECT p.id, $1, $2
|
||||||
FROM projects p
|
FROM projects p
|
||||||
WHERE p.name = $3
|
WHERE p.name = $3 AND p.deleted = FALSE
|
||||||
`, entryID, node.Score, node.Project)
|
`, entryID, node.Score, node.Project)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to insert node for project %s: %w", node.Project, err)
|
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
|
FROM projects p
|
||||||
CROSS JOIN current_info ci
|
CROSS JOIN current_info ci
|
||||||
LEFT JOIN goal_metrics gm ON p.id = gm.project_id
|
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
|
ON CONFLICT (project_id, goal_year, goal_week) DO UPDATE
|
||||||
SET
|
SET
|
||||||
min_goal_score = EXCLUDED.min_goal_score,
|
min_goal_score = EXCLUDED.min_goal_score,
|
||||||
@@ -2931,6 +2954,7 @@ func (a *App) sendWeeklyGoalsTelegramMessage() error {
|
|||||||
WHERE
|
WHERE
|
||||||
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||||
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||||||
|
AND p.deleted = FALSE
|
||||||
ORDER BY
|
ORDER BY
|
||||||
p.name
|
p.name
|
||||||
`
|
`
|
||||||
@@ -3056,6 +3080,7 @@ func (a *App) weeklyGoalsSetupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
WHERE
|
WHERE
|
||||||
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
wg.goal_year = EXTRACT(ISOYEAR FROM CURRENT_DATE)::INTEGER
|
||||||
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
AND wg.goal_week = EXTRACT(WEEK FROM CURRENT_DATE)::INTEGER
|
||||||
|
AND p.deleted = FALSE
|
||||||
ORDER BY
|
ORDER BY
|
||||||
p.name
|
p.name
|
||||||
`
|
`
|
||||||
@@ -3192,6 +3217,8 @@ func (a *App) recreateMaterializedViewHandler(w http.ResponseWriter, r *http.Req
|
|||||||
1, 2, 3
|
1, 2, 3
|
||||||
) agg
|
) agg
|
||||||
ON p.id = agg.project_id
|
ON p.id = agg.project_id
|
||||||
|
WHERE
|
||||||
|
p.deleted = FALSE
|
||||||
ORDER BY
|
ORDER BY
|
||||||
p.id, agg.report_year, agg.report_week
|
p.id, agg.report_year, agg.report_week
|
||||||
`
|
`
|
||||||
@@ -3232,6 +3259,8 @@ func (a *App) getProjectsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
priority
|
priority
|
||||||
FROM
|
FROM
|
||||||
projects
|
projects
|
||||||
|
WHERE
|
||||||
|
deleted = FALSE
|
||||||
ORDER BY
|
ORDER BY
|
||||||
priority ASC NULLS LAST,
|
priority ASC NULLS LAST,
|
||||||
project_name
|
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) {
|
func (a *App) todoistWebhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
// Логирование входящего запроса
|
// Логирование входящего запроса
|
||||||
log.Printf("=== Todoist Webhook Request ===")
|
log.Printf("=== Todoist Webhook Request ===")
|
||||||
@@ -3700,7 +3937,9 @@ func (a *App) getFullStatisticsHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
JOIN
|
JOIN
|
||||||
projects p
|
projects p
|
||||||
-- Присоединяем имя проекта, используя ID из той таблицы, где он не NULL
|
-- Присоединяем имя проекта, используя 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
|
ORDER BY
|
||||||
report_year DESC,
|
report_year DESC,
|
||||||
report_week DESC,
|
report_week DESC,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ LEFT JOIN
|
|||||||
1, 2, 3
|
1, 2, 3
|
||||||
) agg
|
) agg
|
||||||
ON p.id = agg.project_id
|
ON p.id = agg.project_id
|
||||||
|
WHERE
|
||||||
|
p.deleted = FALSE
|
||||||
ORDER BY
|
ORDER BY
|
||||||
p.id, agg.report_year, agg.report_week
|
p.id, agg.report_year, agg.report_week
|
||||||
WITH DATA;
|
WITH DATA;
|
||||||
|
|||||||
13
play-life-backend/migrations/007_add_deleted_to_projects.sql
Normal file
13
play-life-backend/migrations/007_add_deleted_to_projects.sql
Normal file
@@ -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';
|
||||||
|
|
||||||
0
play-life-backend/start_backend.sh
Normal file → Executable file
0
play-life-backend/start_backend.sh
Normal file → Executable file
@@ -162,10 +162,13 @@ function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProject
|
|||||||
<button
|
<button
|
||||||
onClick={() => onNavigate('priorities')}
|
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"
|
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">
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,9 +23,134 @@ import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
|
|||||||
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
|
||||||
const PROJECTS_API_URL = '/projects'
|
const PROJECTS_API_URL = '/projects'
|
||||||
const PRIORITY_UPDATE_API_URL = '/project/priority'
|
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 {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -75,15 +200,16 @@ function SortableProjectItem({ project, index, allProjects, onRemove }) {
|
|||||||
></div>
|
></div>
|
||||||
<span className="font-semibold text-gray-800">{project.name}</span>
|
<span className="font-semibold text-gray-800">{project.name}</span>
|
||||||
</div>
|
</div>
|
||||||
{onRemove && (
|
{onMenuClick && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onRemove(project.name)}
|
onClick={(e) => {
|
||||||
className="ml-2 text-gray-400 hover:text-red-500 transition-colors"
|
e.stopPropagation()
|
||||||
title="Убрать из этого слота"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="text-sm font-semibold text-gray-600 mb-2">{title}</div>
|
<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}
|
project={project}
|
||||||
index={index}
|
index={index}
|
||||||
allProjects={allProjects}
|
allProjects={allProjects}
|
||||||
onRemove={onRemove}
|
onMenuClick={onMenuClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -159,6 +285,8 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
const [mediumPriority, setMediumPriority] = useState([])
|
const [mediumPriority, setMediumPriority] = useState([])
|
||||||
const [lowPriority, setLowPriority] = useState([])
|
const [lowPriority, setLowPriority] = useState([])
|
||||||
const [activeId, setActiveId] = useState(null)
|
const [activeId, setActiveId] = useState(null)
|
||||||
|
const [selectedProject, setSelectedProject] = useState(null) // Для модального окна
|
||||||
|
const [showMoveScreen, setShowMoveScreen] = useState(false) // Для экрана переноса
|
||||||
|
|
||||||
|
|
||||||
const scrollContainerRef = useRef(null)
|
const scrollContainerRef = useRef(null)
|
||||||
@@ -580,30 +708,42 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemove = (projectName, container) => {
|
const handleMenuClick = (project, e) => {
|
||||||
const sourceList =
|
e.stopPropagation()
|
||||||
container === 'max'
|
setSelectedProject(project)
|
||||||
? maxPriority
|
}
|
||||||
: container === 'medium'
|
|
||||||
? mediumPriority
|
|
||||||
: lowPriority
|
|
||||||
|
|
||||||
const project = sourceList.find(p => p.name === projectName)
|
const handleMove = () => {
|
||||||
if (!project) return
|
if (!selectedProject) return
|
||||||
|
setShowMoveScreen(true)
|
||||||
|
}
|
||||||
|
|
||||||
const projectId = project.id ?? project.name
|
const handleDelete = async () => {
|
||||||
if (projectId) {
|
if (!selectedProject) return
|
||||||
skipNextEffectRef.current = true
|
|
||||||
sendPriorityChanges([{ id: projectId, priority: null }])
|
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') {
|
const closeModal = () => {
|
||||||
setMaxPriority(prev => prev.filter(p => p.name !== projectName))
|
setSelectedProject(null)
|
||||||
} else if (container === 'medium') {
|
|
||||||
setMediumPriority(prev => prev.filter(p => p.name !== projectName))
|
|
||||||
}
|
|
||||||
|
|
||||||
moveProjectToLow(project)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const allItems = [
|
const allItems = [
|
||||||
@@ -663,7 +803,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
title="Максимальный приоритет (1 проект)"
|
title="Максимальный приоритет (1 проект)"
|
||||||
projects={maxPriority}
|
projects={maxPriority}
|
||||||
allProjects={allProjects}
|
allProjects={allProjects}
|
||||||
onRemove={(name) => handleRemove(name, 'max')}
|
onMenuClick={handleMenuClick}
|
||||||
maxItems={1}
|
maxItems={1}
|
||||||
containerId="max"
|
containerId="max"
|
||||||
/>
|
/>
|
||||||
@@ -674,7 +814,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
title="Средний приоритет (2 проекта)"
|
title="Средний приоритет (2 проекта)"
|
||||||
projects={mediumPriority}
|
projects={mediumPriority}
|
||||||
allProjects={allProjects}
|
allProjects={allProjects}
|
||||||
onRemove={(name) => handleRemove(name, 'medium')}
|
onMenuClick={handleMenuClick}
|
||||||
maxItems={2}
|
maxItems={2}
|
||||||
containerId="medium"
|
containerId="medium"
|
||||||
/>
|
/>
|
||||||
@@ -685,6 +825,7 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
title="Остальные проекты"
|
title="Остальные проекты"
|
||||||
projects={lowPriority}
|
projects={lowPriority}
|
||||||
allProjects={allProjects}
|
allProjects={allProjects}
|
||||||
|
onMenuClick={handleMenuClick}
|
||||||
containerId="low"
|
containerId="low"
|
||||||
/>
|
/>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
@@ -716,6 +857,52 @@ function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad,
|
|||||||
: null}
|
: null}
|
||||||
</DndContext>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user